From fc4142d5282ae146304bbc818bd53e97df6179e1 Mon Sep 17 00:00:00 2001 From: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:31:49 +0800 Subject: [PATCH] feat(codex): outer OAuth 96h refresh lead and gpt-6.1-sol catalog Align Codex outer/CLI proactive refresh with CLIProxyAPI CodexOuterRefreshLead (96h, feb6398) while keeping SIWC at 5m. Add gpt-6.1-sol from upstream codex_client_models.json (c8a2bfe8). --- .../codex-outer-refresh-96h-gpt-6.1-sol.md | 9 ++ internal/providers/codex/client_ua_test.go | 7 +- internal/providers/codex/credential.go | 21 ++- .../codex/credential_refresh_test.go | 124 ++++++++++++++++++ internal/providers/codex/models.json | 22 ++++ .../providers/codex/s04_interfaces_test.go | 4 +- 6 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 changelog/unreleased/codex-outer-refresh-96h-gpt-6.1-sol.md create mode 100644 internal/providers/codex/credential_refresh_test.go diff --git a/changelog/unreleased/codex-outer-refresh-96h-gpt-6.1-sol.md b/changelog/unreleased/codex-outer-refresh-96h-gpt-6.1-sol.md new file mode 100644 index 00000000..b11e0dec --- /dev/null +++ b/changelog/unreleased/codex-outer-refresh-96h-gpt-6.1-sol.md @@ -0,0 +1,9 @@ +### English + +- Codex outer / CLI OAuth now proactively refreshes with a 96-hour lead (aligned with CLIProxyAPI `CodexOuterRefreshLead`), while Sign in with ChatGPT (SIWC) keeps the short 5-minute lead—so the nominal 10-day JWT is renewed before early upstream invalidation without changing SIWC behavior. +- Codex model catalog adds `gpt-6.1-sol` (GPT-6.1-Sol) from the upstream client model list, with the same slim fields as other `gpt-*` entries. + +### 中文 + +- Codex outer / CLI OAuth 主动刷新提前量对齐 CLIProxyAPI 的 `CodexOuterRefreshLead`(96 小时),Sign in with ChatGPT(SIWC)仍保持 5 分钟短 lead,以便在名义 10 天 JWT 被上游提前作废前续期,且不改变 SIWC 行为。 +- Codex 模型目录新增上游客户端列表中的 `gpt-6.1-sol`(GPT-6.1-Sol),字段结构与其它 `gpt-*` 条目一致。 diff --git a/internal/providers/codex/client_ua_test.go b/internal/providers/codex/client_ua_test.go index b9f2ab9e..cba49792 100644 --- a/internal/providers/codex/client_ua_test.go +++ b/internal/providers/codex/client_ua_test.go @@ -38,7 +38,7 @@ func dualSIWCCredential() Credential { RefreshToken: "siwc-refresh", AccountID: "acct-1", Email: "u@example.com", - ExpiresAt: time.Now().Add(time.Hour).Unix(), + ExpiresAt: time.Now().Add(time.Hour).Unix(), // SIWC lead is 5m AuthMethod: AuthMethodChatGPT, ClientID: "issued-client", DeviceID: "dev-1", @@ -46,7 +46,9 @@ func dualSIWCCredential() Credential { Outer: &OuterPartition{ AccessToken: "outer-token", RefreshToken: "outer-refresh", - ExpiresAt: time.Now().Add(time.Hour).Unix(), + // Outer lead is 96h; keep fixtures outside that window so HTTP tests + // do not hit the real token endpoint. + ExpiresAt: time.Now().Add(10 * 24 * time.Hour).Unix(), }, } } @@ -191,4 +193,3 @@ func TestSetChatHeadersUsesEffectiveCredential(t *testing.T) { t.Fatalf("headers = %v", h) } } - diff --git a/internal/providers/codex/credential.go b/internal/providers/codex/credential.go index 9fde2281..c0da9fb0 100644 --- a/internal/providers/codex/credential.go +++ b/internal/providers/codex/credential.go @@ -60,7 +60,13 @@ const ( ) const ( - refreshLead = 5 * time.Minute + // outerRefreshLead matches CLIProxyAPI CodexOuterRefreshLead (feb6398): the + // nominal 10-day Codex OAuth JWT may be invalidated early upstream, so outer + // / CLI tokens refresh well ahead of expiry. + outerRefreshLead = 96 * time.Hour + // siwcRefreshLead matches CLIProxyAPI CodexSIWCRefreshLead / pi's + // DEFAULT_OAUTH_MINIMUM_VALIDITY_MS (5 minutes). + siwcRefreshLead = 5 * time.Minute loginPendingTTL = 10 * time.Minute ) @@ -210,7 +216,6 @@ func normalizeAuthMethod(method string) string { } } - // UseSIWCForRequest reports whether this request should use the nested SIWC // partition. Codex client User-Agents always ignore use_siwc (CLIProxyAPI d946c4f). func (c Credential) UseSIWCForRequest(userAgent string) bool { @@ -263,6 +268,16 @@ func ValidateCredential(payload []byte) error { return nil } +// refreshLead returns the proactive refresh window for this partition. +// Sign in with ChatGPT keeps the short SIWC lead; outer / CLI OAuth uses the +// conservative 96h lead (CLIProxyAPI feb6398). +func (c Credential) refreshLead() time.Duration { + if c.UsesChatGPT() { + return siwcRefreshLead + } + return outerRefreshLead +} + func (c Credential) needsRefresh(now time.Time) bool { if strings.TrimSpace(c.AccessToken) == "" { return true @@ -270,7 +285,7 @@ func (c Credential) needsRefresh(now time.Time) bool { if c.ExpiresAt <= 0 { return true } - return now.Add(refreshLead).Unix() >= c.ExpiresAt + return now.Add(c.refreshLead()).Unix() >= c.ExpiresAt } func unixSeconds(value int64) int64 { diff --git a/internal/providers/codex/credential_refresh_test.go b/internal/providers/codex/credential_refresh_test.go new file mode 100644 index 00000000..af656f2f --- /dev/null +++ b/internal/providers/codex/credential_refresh_test.go @@ -0,0 +1,124 @@ +package codex + +import ( + "testing" + "time" +) + +func TestRefreshLeadOuterVsSIWC(t *testing.T) { + outer := Credential{ + AccessToken: "outer-token", + RefreshToken: "outer-refresh", + AccountID: "acct-1", + AuthMethod: AuthMethodCLI, + ExpiresAt: time.Now().Add(10 * 24 * time.Hour).Unix(), + } + if got := outer.refreshLead(); got != outerRefreshLead { + t.Fatalf("CLI/outer lead = %v, want %v", got, outerRefreshLead) + } + + siwc := Credential{ + AccessToken: "siwc-token", + RefreshToken: "siwc-refresh", + AccountID: "acct-1", + AuthMethod: AuthMethodChatGPT, + ClientID: "issued-client", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + if got := siwc.refreshLead(); got != siwcRefreshLead { + t.Fatalf("SIWC lead = %v, want %v", got, siwcRefreshLead) + } + + // Codex-client EffectiveForRequest selects the outer partition (AuthMethodCLI), + // so the long outer lead applies even on a dual UseSIWC credential. + dual := dualSIWCCredential() + effective, err := dual.EffectiveForRequest("codex-rs/0.1") + if err != nil { + t.Fatal(err) + } + if !effective.refreshingOuter() { + t.Fatal("expected refreshingOuter on Codex UA dual credential") + } + if got := effective.refreshLead(); got != outerRefreshLead { + t.Fatalf("Codex UA effective lead = %v, want outer %v", got, outerRefreshLead) + } + if got := dual.refreshLead(); got != siwcRefreshLead { + t.Fatalf("dual primary (SIWC) lead = %v, want SIWC %v", got, siwcRefreshLead) + } +} + +func TestNeedsRefreshOuterLead96h(t *testing.T) { + now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.UTC) + + // Nominal 10-day JWT with 48h remaining: still inside the 96h outer lead. + outerSoon := Credential{ + AccessToken: "tok", + RefreshToken: "ref", + AccountID: "acct", + AuthMethod: AuthMethodCLI, + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + if !outerSoon.needsRefresh(now) { + t.Fatal("outer with 48h remaining should refresh under 96h lead") + } + + // More than 96h remaining: no proactive refresh yet. + outerFresh := outerSoon + outerFresh.ExpiresAt = now.Add(100 * time.Hour).Unix() + if outerFresh.needsRefresh(now) { + t.Fatal("outer with 100h remaining should not refresh yet") + } + + // SIWC keeps the short 5m lead: 10m remaining is still fine. + siwcFresh := Credential{ + AccessToken: "tok", + RefreshToken: "ref", + AccountID: "acct", + AuthMethod: AuthMethodChatGPT, + ClientID: "cid", + ExpiresAt: now.Add(10 * time.Minute).Unix(), + } + if siwcFresh.needsRefresh(now) { + t.Fatal("SIWC with 10m remaining should not refresh under 5m lead") + } + siwcSoon := siwcFresh + siwcSoon.ExpiresAt = now.Add(3 * time.Minute).Unix() + if !siwcSoon.needsRefresh(now) { + t.Fatal("SIWC with 3m remaining should refresh under 5m lead") + } + + // Same wall-clock remaining as the outerSoon case must NOT trigger SIWC refresh. + siwc48h := siwcFresh + siwc48h.ExpiresAt = now.Add(48 * time.Hour).Unix() + if siwc48h.needsRefresh(now) { + t.Fatal("SIWC with 48h remaining must not use the outer 96h lead") + } +} + +func TestCatalogIncludesGPT61Sol(t *testing.T) { + entry, ok := lookupModel("gpt-6.1-sol") + if !ok { + t.Fatal("gpt-6.1-sol missing from Codex models.json") + } + if entry.DisplayName != "GPT-6.1-Sol" { + t.Fatalf("display_name = %q", entry.DisplayName) + } + if entry.ContextWindow != 272000 || entry.MaxContextWindow != 872000 { + t.Fatalf("context windows = %d/%d", entry.ContextWindow, entry.MaxContextWindow) + } + if entry.DefaultReasoning != "low" { + t.Fatalf("default_reasoning_level = %q, want low", entry.DefaultReasoning) + } + if !entry.UseResponsesLite || !entry.SupportedInAPI || !entry.PreferWebsockets { + t.Fatalf("flags lite=%v api=%v ws=%v", entry.UseResponsesLite, entry.SupportedInAPI, entry.PreferWebsockets) + } + wantLevels := []string{"low", "medium", "high", "xhigh", "max", "ultra"} + if len(entry.ReasoningLevels) != len(wantLevels) { + t.Fatalf("reasoning levels = %v, want %v", entry.ReasoningLevels, wantLevels) + } + for i, level := range wantLevels { + if entry.ReasoningLevels[i] != level { + t.Fatalf("reasoning levels = %v, want %v", entry.ReasoningLevels, wantLevels) + } + } +} diff --git a/internal/providers/codex/models.json b/internal/providers/codex/models.json index ce233bb7..098c50d7 100644 --- a/internal/providers/codex/models.json +++ b/internal/providers/codex/models.json @@ -1,5 +1,27 @@ { "models": [ + { + "slug": "gpt-6.1-sol", + "display_name": "GPT-6.1-Sol", + "context_window": 272000, + "max_context_window": 872000, + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultra" + ], + "input_modalities": [ + "text", + "image" + ], + "prefer_websockets": true, + "use_responses_lite": true, + "supported_in_api": true + }, { "slug": "gpt-6-astra", "display_name": "GPT-6-Astra", diff --git a/internal/providers/codex/s04_interfaces_test.go b/internal/providers/codex/s04_interfaces_test.go index 0a5cc2ef..f2f79545 100644 --- a/internal/providers/codex/s04_interfaces_test.go +++ b/internal/providers/codex/s04_interfaces_test.go @@ -57,7 +57,7 @@ func credentialPayload() []byte { RefreshToken: "rt", AccountID: "acct_123", Email: "u@example.com", - ExpiresAt: time.Now().Add(time.Hour).Unix(), + ExpiresAt: time.Now().Add(10 * 24 * time.Hour).Unix(), }.Encode() return payload } @@ -143,7 +143,7 @@ func TestProbeRecoversMissingAccountIDFromIDToken(t *testing.T) { IDToken: idToken, AccessToken: "at", RefreshToken: "rt", - ExpiresAt: time.Now().Add(time.Hour).Unix(), + ExpiresAt: time.Now().Add(10 * 24 * time.Hour).Unix(), }) store := testStore{items: map[string][]byte{"acc-1": payload}} health, err := NewClient(store).Probe(context.Background(), "acc-1")