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
9 changes: 9 additions & 0 deletions changelog/unreleased/codex-outer-refresh-96h-gpt-6.1-sol.md
Original file line number Diff line number Diff line change
@@ -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-*` 条目一致。
7 changes: 4 additions & 3 deletions internal/providers/codex/client_ua_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,17 @@ 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",
UseSIWC: true,
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(),
},
}
}
Expand Down Expand Up @@ -191,4 +193,3 @@ func TestSetChatHeadersUsesEffectiveCredential(t *testing.T) {
t.Fatalf("headers = %v", h)
}
}

21 changes: 18 additions & 3 deletions internal/providers/codex/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -263,14 +268,24 @@ 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
}
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 {
Expand Down
124 changes: 124 additions & 0 deletions internal/providers/codex/credential_refresh_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
22 changes: 22 additions & 0 deletions internal/providers/codex/models.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/providers/codex/s04_interfaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down
Loading