Skip to content

Commit dc8743f

Browse files
Merge upstream/main (auto-sync feat/copilot)
- 365e8fc feat(antigravity): HOME reasoning replay for Gemini models - 62c4b37 Revert "feat(antigravity): HOME reasoning replay for Gemini models" - ac8fb97 feat(thinking): remove `thinkingConfig` for `ModeNone` with zero budget and no level - c13dbcc feat(translator): add test and logic to ensure `object` schemas include `properties` field - 41c52b9 test(management): add concurrency test for Codex OAuth session handling - ae6c5ea feat(runtime): add support for `gpt-image-1.5` and direct image API proxying - 052f193 fix(auth): classify transport errors as `home_unavailable` with retryable flag - 1d0551a feat(config): improve config reload handling and introduce async management save hook - c020e2d feat(translator): drop `apply_patch` custom tool in OpenAI responses - 893412e feat(translator): normalize `service_tier` in Codex requests and add tests - 4926630 feat(translator): support namespace tools in OpenAI response transformations - d33ac5e feat(auth): add transient error cooldown configuration and adjust retry logic - 07c297a feat(auth): add persistent cooldown state management with file-backed store
2 parents c930f6b + 07c297a commit dc8743f

44 files changed

Lines changed: 2845 additions & 126 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/server/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ func main() {
507507
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
508508
redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
509509
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
510+
coreauth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
510511

511512
if err = logging.ConfigureLogOutput(cfg); err != nil {
512513
log.Errorf("failed to configure log output: %v", err)

config.example.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ max-retry-interval: 30
119119
# When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states).
120120
disable-cooling: false
121121

122+
# When true, persist per-auth cooldown status as .cds files next to auth files.
123+
# Default is false; when false, cooldown status is kept in memory only.
124+
save-cooldown-status: false
125+
126+
# Cooldown duration in seconds for transient upstream errors (408/500/502/503/504).
127+
# Set to 0 to keep the legacy 60-second cooldown; set to -1 to disable transient error cooldowns.
128+
transient-error-cooldown-seconds: 0
129+
122130
# When true, globally disable Claude request cloaking (the Claude Code CLI disguise and
123131
# system prompt replacement), so the original system prompt is passed through to Claude as-is.
124132
# Individual credentials can still override this: a claude-api-key entry via its "cloak.mode",
@@ -132,7 +140,7 @@ disable-claude-cloak-mode: false
132140
# - "passthrough": never inject or strip image_generation on non-images endpoints (forward the client payload unchanged); behaves like "chat" on /v1/images/* endpoints.
133141
disable-image-generation: false
134142

135-
# Base model used when proxying gpt-image-2 via the hosted image_generation tool (Responses API).
143+
# Base model used by the legacy hosted image_generation tool path when a Codex image request is not proxied directly through the Image API.
136144
# Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini".
137145
# gpt-image-2-base-model: "gpt-5.4-mini"
138146

internal/api/handlers/management/auth_files.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
geminiAuth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini"
2929
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi"
3030
xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai"
31+
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
3132
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
3233
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
3334
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
@@ -58,11 +59,18 @@ type callbackForwarder struct {
5859
done chan struct{}
5960
}
6061

62+
type codexOAuthService interface {
63+
GenerateAuthURL(state string, pkceCodes *codex.PKCECodes) (string, error)
64+
ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *codex.PKCECodes) (*codex.CodexAuthBundle, error)
65+
CreateTokenStorage(bundle *codex.CodexAuthBundle) *codex.CodexTokenStorage
66+
}
67+
6168
var (
6269
callbackForwardersMu sync.Mutex
6370
callbackForwarders = make(map[int]*callbackForwarder)
6471
errAuthFileMustBeJSON = errors.New("auth file must be .json")
6572
errAuthFileNotFound = errors.New("auth file not found")
73+
newCodexOAuthService = func(cfg *config.Config) codexOAuthService { return codex.NewCodexAuth(cfg) }
6674
)
6775

6876
func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
@@ -1891,7 +1899,6 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
18911899
}
18921900
fmt.Println("You can now use Claude services through this CLI")
18931901
CompleteOAuthSession(state)
1894-
CompleteOAuthSessionsByProvider("anthropic")
18951902
}()
18961903

18971904
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
@@ -2149,7 +2156,6 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
21492156
}
21502157

21512158
CompleteOAuthSession(state)
2152-
CompleteOAuthSessionsByProvider("gemini")
21532159
fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath)
21542160
}()
21552161

@@ -2179,7 +2185,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
21792185
}
21802186

21812187
// Initialize Codex auth service
2182-
openaiAuth := codex.NewCodexAuth(h.cfg)
2188+
openaiAuth := newCodexOAuthService(h.cfg)
21832189

21842190
// Generate authorization URL
21852191
authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
@@ -2296,7 +2302,6 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
22962302
}
22972303
fmt.Println("You can now use Codex services through this CLI")
22982304
CompleteOAuthSession(state)
2299-
CompleteOAuthSessionsByProvider("codex")
23002305
}()
23012306

23022307
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
@@ -2456,7 +2461,6 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
24562461
}
24572462

24582463
CompleteOAuthSession(state)
2459-
CompleteOAuthSessionsByProvider("antigravity")
24602464
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
24612465
if projectID != "" {
24622466
fmt.Printf("Using GCP project: %s\n", util.HideAPIKey(projectID))
@@ -2638,7 +2642,6 @@ func (h *Handler) RequestXAIToken(c *gin.Context) {
26382642
}
26392643

26402644
CompleteOAuthSession(state)
2641-
CompleteOAuthSessionsByProvider("xai")
26422645
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
26432646
fmt.Println("You can now use xAI services through this CLI")
26442647
}()
@@ -2717,7 +2720,6 @@ func (h *Handler) RequestKimiToken(c *gin.Context) {
27172720
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
27182721
fmt.Println("You can now use Kimi services through this CLI")
27192722
CompleteOAuthSession(state)
2720-
CompleteOAuthSessionsByProvider("kimi")
27212723
}()
27222724

27232725
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})

internal/api/handlers/management/handler.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,13 @@ func (h *Handler) persistLocked(c *gin.Context) bool {
411411
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
412412
return false
413413
}
414+
snapshot := h.reloadSnapshotConfigLocked()
414415
c.JSON(http.StatusOK, gin.H{"status": "ok"})
416+
var reqCtx context.Context
417+
if c != nil && c.Request != nil {
418+
reqCtx = c.Request.Context()
419+
}
420+
h.reloadConfigAfterManagementSaveAsync(reqCtx, snapshot)
415421
return true
416422
}
417423

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package management
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"path/filepath"
9+
"testing"
10+
"time"
11+
12+
"github.com/gin-gonic/gin"
13+
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
14+
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
15+
)
16+
17+
type fakeCodexOAuthService struct{}
18+
19+
func (f *fakeCodexOAuthService) GenerateAuthURL(state string, pkceCodes *codex.PKCECodes) (string, error) {
20+
return "https://auth.example.test/oauth?state=" + state, nil
21+
}
22+
23+
func (f *fakeCodexOAuthService) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *codex.PKCECodes) (*codex.CodexAuthBundle, error) {
24+
now := time.Now()
25+
return &codex.CodexAuthBundle{
26+
TokenData: codex.CodexTokenData{
27+
IDToken: "invalid-test-id-token",
28+
AccessToken: "access-" + code,
29+
RefreshToken: "refresh-" + code,
30+
Email: "codex-" + code + "@example.test",
31+
Expire: now.Add(time.Hour).Format(time.RFC3339),
32+
},
33+
LastRefresh: now.Format(time.RFC3339),
34+
}, nil
35+
}
36+
37+
func (f *fakeCodexOAuthService) CreateTokenStorage(bundle *codex.CodexAuthBundle) *codex.CodexTokenStorage {
38+
return &codex.CodexTokenStorage{
39+
IDToken: bundle.TokenData.IDToken,
40+
AccessToken: bundle.TokenData.AccessToken,
41+
RefreshToken: bundle.TokenData.RefreshToken,
42+
AccountID: bundle.TokenData.AccountID,
43+
LastRefresh: bundle.LastRefresh,
44+
Email: bundle.TokenData.Email,
45+
Expire: bundle.TokenData.Expire,
46+
}
47+
}
48+
49+
func TestRequestCodexTokenCompletionKeepsConcurrentSessionPending(t *testing.T) {
50+
originalNewCodexOAuthService := newCodexOAuthService
51+
newCodexOAuthService = func(cfg *config.Config) codexOAuthService {
52+
return &fakeCodexOAuthService{}
53+
}
54+
defer func() {
55+
newCodexOAuthService = originalNewCodexOAuthService
56+
}()
57+
58+
authDir := filepath.Join(t.TempDir(), "auths")
59+
handler := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
60+
router := gin.New()
61+
router.GET("/codex-auth-url", handler.RequestCodexToken)
62+
63+
firstState := requestCodexTokenState(t, router)
64+
secondState := requestCodexTokenState(t, router)
65+
defer CompleteOAuthSession(firstState)
66+
defer CompleteOAuthSession(secondState)
67+
68+
if _, errWrite := WriteOAuthCallbackFileForPendingSession(authDir, "codex", firstState, "first-code", ""); errWrite != nil {
69+
t.Fatalf("write first callback file: %v", errWrite)
70+
}
71+
72+
waitForOAuthSessionDone(t, firstState)
73+
if !IsOAuthSessionPending(secondState, "codex") {
74+
t.Fatalf("expected concurrent codex session %s to remain pending after %s completed", secondState, firstState)
75+
}
76+
}
77+
78+
func requestCodexTokenState(t *testing.T, router http.Handler) string {
79+
t.Helper()
80+
81+
req := httptest.NewRequest(http.MethodGet, "/codex-auth-url", nil)
82+
w := httptest.NewRecorder()
83+
router.ServeHTTP(w, req)
84+
if w.Code != http.StatusOK {
85+
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, w.Code, w.Body.String())
86+
}
87+
88+
var payload struct {
89+
State string `json:"state"`
90+
}
91+
if errDecode := json.Unmarshal(w.Body.Bytes(), &payload); errDecode != nil {
92+
t.Fatalf("decode codex auth URL response: %v", errDecode)
93+
}
94+
if payload.State == "" {
95+
t.Fatalf("expected codex auth URL response to include state")
96+
}
97+
return payload.State
98+
}
99+
100+
func waitForOAuthSessionDone(t *testing.T, state string) {
101+
t.Helper()
102+
103+
deadline := time.Now().Add(3 * time.Second)
104+
for time.Now().Before(deadline) {
105+
if !IsOAuthSessionPending(state, "codex") {
106+
return
107+
}
108+
time.Sleep(20 * time.Millisecond)
109+
}
110+
t.Fatalf("timed out waiting for codex session %s to complete", state)
111+
}

internal/api/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
328328
}
329329
managementasset.SetCurrentConfig(cfg)
330330
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
331+
auth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
331332
applySignatureCacheConfig(nil, cfg)
332333
// Initialize management handler
333334
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
@@ -1598,6 +1599,9 @@ func (s *Server) UpdateClients(cfg *config.Config) {
15981599
if oldCfg == nil || oldCfg.DisableCooling != cfg.DisableCooling {
15991600
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
16001601
}
1602+
if oldCfg == nil || oldCfg.TransientErrorCooldownSeconds != cfg.TransientErrorCooldownSeconds {
1603+
auth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
1604+
}
16011605

16021606
if oldCfg != nil && oldCfg.DisableImageGeneration != cfg.DisableImageGeneration {
16031607
log.Infof("disable-image-generation updated: %v -> %v", oldCfg.DisableImageGeneration, cfg.DisableImageGeneration)

internal/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ type Config struct {
8080
// DisableCooling disables quota cooldown scheduling when true.
8181
DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"`
8282

83+
// SaveCooldownStatus persists runtime cooldown status next to auth files when true.
84+
SaveCooldownStatus bool `yaml:"save-cooldown-status" json:"save-cooldown-status"`
85+
86+
// TransientErrorCooldownSeconds controls cooldowns for transient upstream errors.
87+
// 0 keeps the legacy default cooldown. Negative values disable these cooldowns.
88+
TransientErrorCooldownSeconds int `yaml:"transient-error-cooldown-seconds" json:"transient-error-cooldown-seconds"`
89+
8390
// AuthAutoRefreshWorkers overrides the size of the core auth auto-refresh worker pool.
8491
// When <= 0, the default worker count is used.
8592
AuthAutoRefreshWorkers int `yaml:"auth-auto-refresh-workers" json:"auth-auto-refresh-workers"`
@@ -684,6 +691,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
684691
cfg.UsageStatisticsEnabled = false
685692
cfg.RedisUsageQueueRetentionSeconds = 60
686693
cfg.DisableCooling = false
694+
cfg.SaveCooldownStatus = false
695+
cfg.TransientErrorCooldownSeconds = 0
687696
cfg.DisableImageGeneration = DisableImageGenerationOff
688697
cfg.Pprof.Enable = false
689698
cfg.Pprof.Addr = DefaultPprofAddr

internal/config/parse.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func ParseConfigBytes(data []byte) (*Config, error) {
2525
cfg.UsageStatisticsEnabled = false
2626
cfg.RedisUsageQueueRetentionSeconds = 60
2727
cfg.DisableCooling = false
28+
cfg.SaveCooldownStatus = false
29+
cfg.TransientErrorCooldownSeconds = 0
2830
cfg.DisableImageGeneration = DisableImageGenerationOff
2931
cfg.Pprof.Enable = false
3032
cfg.Pprof.Addr = DefaultPprofAddr

internal/config/sdk_config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ type SDKConfig struct {
2121
// sent it and do not inject it otherwise; on /v1/images/generations and /v1/images/edits behave like "chat".
2222
DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"`
2323

24-
// GPTImage2BaseModel sets the base (mainline) model used when proxying GPT Image 2
25-
// requests via the hosted image_generation tool (e.g. Codex OAuth /v1/images/*).
24+
// GPTImage2BaseModel sets the base (mainline) model used by the legacy hosted
25+
// image_generation tool path when a Codex image request is not proxied directly
26+
// through the Image API.
2627
//
2728
// The value must start with "gpt-" (case-insensitive). If empty or invalid, the
2829
// default base model ("gpt-5.4-mini") is used.

internal/registry/model_definitions.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
)
88

99
const (
10+
codexBuiltinImage15ModelID = "gpt-image-1.5"
1011
codexBuiltinImageModelID = "gpt-image-2"
1112
xaiBuiltinImageModelID = "grok-imagine-image"
1213
xaiBuiltinImageQualityModelID = "grok-imagine-image-quality"
@@ -119,7 +120,7 @@ func GetXAIModels() []*ModelInfo {
119120
// not depend on remote models.json updates. Built-ins replace any matching IDs
120121
// already present in the provided slice.
121122
func WithCodexBuiltins(models []*ModelInfo) []*ModelInfo {
122-
return upsertModelInfos(models, codexBuiltinImageModelInfo())
123+
return upsertModelInfos(models, codexBuiltinImage15ModelInfo(), codexBuiltinImageModelInfo())
123124
}
124125

125126
// WithXAIBuiltins injects hard-coded xAI image/video model definitions that should
@@ -136,6 +137,18 @@ func normalizeAntigravityCapabilityModelID(modelID string) string {
136137
return modelID
137138
}
138139

140+
func codexBuiltinImage15ModelInfo() *ModelInfo {
141+
return &ModelInfo{
142+
ID: codexBuiltinImage15ModelID,
143+
Object: "model",
144+
Created: 1704067200, // 2024-01-01
145+
OwnedBy: "openai",
146+
Type: "openai",
147+
DisplayName: "GPT Image 1.5",
148+
Version: codexBuiltinImage15ModelID,
149+
}
150+
}
151+
139152
func codexBuiltinImageModelInfo() *ModelInfo {
140153
return &ModelInfo{
141154
ID: codexBuiltinImageModelID,

0 commit comments

Comments
 (0)