diff --git a/dto/channel_settings.go b/dto/channel_settings.go index c92a3f988a3a..925daf1c577d 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -43,6 +43,7 @@ type ChannelOtherSettings struct { AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式) AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AutoResetUsageEnabled bool `json:"auto_reset_usage_enabled,omitempty"` // 是否在限流时自动使用一次可用重置次数 AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) DisableTaskPollingSleep bool `json:"disable_task_polling_sleep,omitempty"` // 是否跳过异步任务轮询间隔 AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index ef4d4fa04125..d96cbd4f3473 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -1,26 +1,55 @@ package codex import ( + "context" + "crypto/sha256" "encoding/json" "errors" + "fmt" "io" "net/http" "strings" + "time" "github.com/QuantumNous/new-api/common" + projectconstant "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/openai" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" + "golang.org/x/sync/singleflight" ) type Adaptor struct { } +var ( + codexAutoResetTimeout = 15 * time.Second + codexAutoResetGroup singleflight.Group +) + +const codexAutoResetLockTTL = 15 * time.Minute + +type codexRateLimitWindow struct { + UsedPercent float64 `json:"used_percent"` + LimitWindowSeconds int64 `json:"limit_window_seconds"` +} + +type codexUsagePayload struct { + PlanType string `json:"plan_type"` + RateLimit struct { + PlanType string `json:"plan_type"` + PrimaryWindow *codexRateLimitWindow `json:"primary_window"` + SecondaryWindow *codexRateLimitWindow `json:"secondary_window"` + } `json:"rate_limit"` +} + func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { return nil, errors.New("codex channel: endpoint not supported") } @@ -108,7 +137,198 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo } func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { - return channel.DoApiRequest(a, c, info, requestBody) + if info == nil || !info.ChannelOtherSettings.AutoResetUsageEnabled || requestBody == nil { + return channel.DoApiRequest(a, c, info, requestBody) + } + + maxBytes := int64(projectconstant.MaxRequestBodyMB) + if maxBytes <= 0 { + maxBytes = 128 + } + storage, err := common.CreateBodyStorageFromReader(requestBody, info.UpstreamRequestBodySize, maxBytes<<20) + if err != nil { + return nil, err + } + defer storage.Close() + + if _, err = storage.Seek(0, io.SeekStart); err != nil { + return nil, err + } + resp, err := channel.DoApiRequest(a, c, info, common.ReaderOnly(storage)) + if err != nil { + return nil, err + } + if resp == nil || resp.StatusCode != http.StatusTooManyRequests { + return resp, nil + } + + if !consumeCodexResetCredit(c, info) { + return resp, nil + } + _ = resp.Body.Close() + + if _, err = storage.Seek(0, io.SeekStart); err != nil { + return nil, err + } + return channel.DoApiRequest(a, c, info, common.ReaderOnly(storage)) +} + +func consumeCodexResetCredit(c *gin.Context, info *relaycommon.RelayInfo) bool { + oauthKey, err := ParseOAuthKey(strings.TrimSpace(info.ApiKey)) + if err != nil { + logger.LogWarn(c, "codex auto reset usage skipped: "+err.Error()) + return false + } + + client := service.GetHttpClient() + if info.ChannelSetting.Proxy != "" { + client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + if err != nil { + logger.LogWarn(c, "codex auto reset usage skipped: "+err.Error()) + return false + } + } + + requestContext := context.Background() + if c != nil && c.Request != nil { + requestContext = c.Request.Context() + } + resetKey := codexAutoResetKey(info.ChannelBaseUrl, oauthKey.AccountID) + resultChannel := codexAutoResetGroup.DoChan(resetKey, func() (any, error) { + ctx, cancel := context.WithTimeout(context.Background(), codexAutoResetTimeout) + defer cancel() + + if common.RedisEnabled && common.RDB != nil { + acquired, lockErr := common.RDB.SetNX( + ctx, + "codex:auto-reset:lock:"+resetKey, + "1", + codexAutoResetLockTTL, + ).Result() + if lockErr == nil && !acquired { + return false, nil + } + if lockErr != nil { + logger.LogWarn(c, "codex auto reset Redis lock unavailable: "+lockErr.Error()) + } + } + + eligible, eligibilityErr := checkCodexAutoResetEligibility(ctx, client, info, oauthKey) + if eligibilityErr != nil || !eligible { + return false, eligibilityErr + } + return performCodexAutoReset(ctx, client, info, oauthKey) + }) + + select { + case result := <-resultChannel: + if result.Err != nil { + if !result.Shared { + logger.LogWarn(c, "codex auto reset usage failed: "+result.Err.Error()) + } + return false + } + reset, ok := result.Val.(bool) + if reset && !result.Shared { + logger.LogInfo(c, "codex auto reset usage ready for retry") + } + return ok && reset + case <-requestContext.Done(): + return false + } +} + +func checkCodexAutoResetEligibility(ctx context.Context, client *http.Client, info *relaycommon.RelayInfo, oauthKey *OAuthKey) (bool, error) { + statusCode, body, err := service.FetchCodexWhamUsage( + ctx, + client, + info.ChannelBaseUrl, + oauthKey.AccessToken, + oauthKey.AccountID, + ) + if err != nil { + return false, fmt.Errorf("fetch usage: %w", err) + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return false, fmt.Errorf("fetch usage: upstream_status=%d", statusCode) + } + + var usage codexUsagePayload + if err = common.Unmarshal(body, &usage); err != nil { + return false, fmt.Errorf("parse usage: %w", err) + } + weeklyExhausted := false + for _, window := range []*codexRateLimitWindow{ + usage.RateLimit.PrimaryWindow, + usage.RateLimit.SecondaryWindow, + } { + if window != nil && window.LimitWindowSeconds >= int64((24*time.Hour)/time.Second) && window.UsedPercent >= 100 { + weeklyExhausted = true + break + } + } + planType := usage.PlanType + if planType == "" { + planType = usage.RateLimit.PlanType + } + if !weeklyExhausted && strings.EqualFold(planType, "free") && usage.RateLimit.PrimaryWindow != nil { + weeklyExhausted = usage.RateLimit.PrimaryWindow.UsedPercent >= 100 + } + if !weeklyExhausted { + return false, nil + } + + statusCode, body, err = service.FetchCodexWhamRateLimitResetCredits( + ctx, + client, + info.ChannelBaseUrl, + oauthKey.AccessToken, + oauthKey.AccountID, + ) + if err != nil { + return false, fmt.Errorf("fetch reset credits: %w", err) + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return false, fmt.Errorf("fetch reset credits: upstream_status=%d", statusCode) + } + var credits struct { + AvailableCount int `json:"available_count"` + } + if err = common.Unmarshal(body, &credits); err != nil { + return false, fmt.Errorf("parse reset credits: %w", err) + } + if credits.AvailableCount <= 0 { + return false, nil + } + return true, nil +} + +func performCodexAutoReset(ctx context.Context, client *http.Client, info *relaycommon.RelayInfo, oauthKey *OAuthKey) (bool, error) { + statusCode, body, err := service.ConsumeCodexWhamRateLimitResetCredit( + ctx, + client, + info.ChannelBaseUrl, + oauthKey.AccessToken, + oauthKey.AccountID, + ) + if err != nil { + return false, fmt.Errorf("consume reset credit: %w", err) + } + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return false, fmt.Errorf("consume reset credit: upstream_status=%d", statusCode) + } + var resetResult struct { + WindowsReset int `json:"windows_reset"` + } + if err = common.Unmarshal(body, &resetResult); err != nil { + return false, fmt.Errorf("parse reset result: %w", err) + } + return resetResult.WindowsReset > 0, nil +} + +func codexAutoResetKey(baseURL string, accountID string) string { + identity := strings.TrimRight(strings.TrimSpace(baseURL), "/") + "|" + strings.TrimSpace(accountID) + return fmt.Sprintf("%x", sha256.Sum256([]byte(identity))) } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { diff --git a/relay/channel/codex/adaptor_test.go b/relay/channel/codex/adaptor_test.go new file mode 100644 index 000000000000..5f59f7ec491b --- /dev/null +++ b/relay/channel/codex/adaptor_test.go @@ -0,0 +1,381 @@ +package codex + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/go-redis/redis/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newAutoResetTestRequest(baseURL string, enabled bool) (*gin.Context, *relaycommon.RelayInfo, string) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + requestBody := `{"model":"gpt-5-codex","input":"hello"}` + info := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeResponses, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeCodex, + ChannelId: 1, + ChannelBaseUrl: baseURL, + ApiKey: `{"access_token":"test-access-token","account_id":"test-account"}`, + ChannelOtherSettings: dto.ChannelOtherSettings{ + AutoResetUsageEnabled: enabled, + }, + }, + UpstreamRequestBodySize: int64(len(requestBody)), + } + return c, info, requestBody +} + +func setAutoResetTestRedis(t *testing.T, enabled bool, client *redis.Client) { + t.Helper() + previousEnabled := common.RedisEnabled + previousClient := common.RDB + common.RedisEnabled = enabled + common.RDB = client + t.Cleanup(func() { + common.RedisEnabled = previousEnabled + common.RDB = previousClient + }) +} + +func TestAutoResetUsageRetriesAfterRateLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + setAutoResetTestRedis(t, false, nil) + + var responseCalls, usageCalls, creditCalls, resetCalls int + var bodies []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/backend-api/codex/responses": + responseCalls++ + body, _ := io.ReadAll(r.Body) + bodies = append(bodies, string(body)) + assert.Equal(t, "Bearer test-access-token", r.Header.Get("Authorization")) + assert.Equal(t, "test-account", r.Header.Get("chatgpt-account-id")) + if responseCalls == 1 { + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + case "/backend-api/wham/usage": + usageCalls++ + _, _ = w.Write([]byte(`{"rate_limit":{"secondary_window":{"used_percent":100,"limit_window_seconds":604800}}}`)) + case "/backend-api/wham/rate-limit-reset-credits": + creditCalls++ + _, _ = w.Write([]byte(`{"available_count":1}`)) + case "/backend-api/wham/rate-limit-reset-credits/consume": + resetCalls++ + _, _ = w.Write([]byte(`{"windows_reset":1}`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + c, info, requestBody := newAutoResetTestRequest(upstream.URL, true) + respAny, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(requestBody)) + require.NoError(t, err) + resp, ok := respAny.(*http.Response) + require.True(t, ok) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 2, responseCalls) + assert.Equal(t, 1, usageCalls) + assert.Equal(t, 1, creditCalls) + assert.Equal(t, 1, resetCalls) + assert.Equal(t, []string{requestBody, requestBody}, bodies) +} + +func TestAutoResetUsageRejectsIneligibleReset(t *testing.T) { + tests := []struct { + name string + enabled bool + usage string + credits string + consume string + usageCalls int + creditCalls int + resetCalls int + }{ + { + name: "disabled", + enabled: false, + }, + { + name: "weekly quota remains", + enabled: true, + usage: `{"rate_limit":{"primary_window":{"used_percent":100,"limit_window_seconds":18000},"secondary_window":{"used_percent":50,"limit_window_seconds":604800}}}`, + usageCalls: 1, + }, + { + name: "no reset credits", + enabled: true, + usage: `{"rate_limit":{"secondary_window":{"used_percent":100,"limit_window_seconds":604800}}}`, + credits: `{"available_count":0}`, + usageCalls: 1, + creditCalls: 1, + }, + { + name: "no window reset", + enabled: true, + usage: `{"rate_limit":{"secondary_window":{"used_percent":100,"limit_window_seconds":604800}}}`, + credits: `{"available_count":1}`, + consume: `{"windows_reset":0}`, + usageCalls: 1, + creditCalls: 1, + resetCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + setAutoResetTestRedis(t, false, nil) + + var responseCalls, usageCalls, creditCalls, resetCalls int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/backend-api/codex/responses": + responseCalls++ + w.WriteHeader(http.StatusTooManyRequests) + case "/backend-api/wham/usage": + usageCalls++ + _, _ = w.Write([]byte(tt.usage)) + case "/backend-api/wham/rate-limit-reset-credits": + creditCalls++ + _, _ = w.Write([]byte(tt.credits)) + case "/backend-api/wham/rate-limit-reset-credits/consume": + resetCalls++ + _, _ = w.Write([]byte(tt.consume)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + c, info, requestBody := newAutoResetTestRequest(upstream.URL, tt.enabled) + respAny, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(requestBody)) + require.NoError(t, err) + resp, ok := respAny.(*http.Response) + require.True(t, ok) + defer resp.Body.Close() + + assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + assert.Equal(t, 1, responseCalls) + assert.Equal(t, tt.usageCalls, usageCalls) + assert.Equal(t, tt.creditCalls, creditCalls) + assert.Equal(t, tt.resetCalls, resetCalls) + }) + } +} + +func TestAutoResetUsageHasTimeout(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + setAutoResetTestRedis(t, false, nil) + originalTimeout := codexAutoResetTimeout + codexAutoResetTimeout = 50 * time.Millisecond + t.Cleanup(func() { codexAutoResetTimeout = originalTimeout }) + + var usageCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/backend-api/codex/responses": + w.WriteHeader(http.StatusTooManyRequests) + case "/backend-api/wham/usage": + usageCalls.Add(1) + <-r.Context().Done() + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + c, info, requestBody := newAutoResetTestRequest(upstream.URL, true) + respAny, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(requestBody)) + require.NoError(t, err) + resp, ok := respAny.(*http.Response) + require.True(t, ok) + defer resp.Body.Close() + + assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + assert.Equal(t, int32(1), usageCalls.Load()) +} + +func TestAutoResetUsageCoalescesConcurrentResets(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + setAutoResetTestRedis(t, false, nil) + + var initialCalls, usageCalls, creditCalls, resetCalls atomic.Int32 + var resetDone atomic.Bool + var bothInitialOnce sync.Once + bothInitial := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/backend-api/codex/responses": + if resetDone.Load() { + w.WriteHeader(http.StatusOK) + return + } + if initialCalls.Add(1) == 2 { + bothInitialOnce.Do(func() { close(bothInitial) }) + } + w.WriteHeader(http.StatusTooManyRequests) + case "/backend-api/wham/usage": + usageCalls.Add(1) + select { + case <-bothInitial: + _, _ = w.Write([]byte(`{"rate_limit":{"secondary_window":{"used_percent":100,"limit_window_seconds":604800}}}`)) + case <-r.Context().Done(): + } + case "/backend-api/wham/rate-limit-reset-credits": + creditCalls.Add(1) + _, _ = w.Write([]byte(`{"available_count":1}`)) + case "/backend-api/wham/rate-limit-reset-credits/consume": + resetCalls.Add(1) + resetDone.Store(true) + _, _ = w.Write([]byte(`{"windows_reset":1}`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + type result struct { + status int + err error + } + results := make(chan result, 2) + for range 2 { + go func() { + c, info, requestBody := newAutoResetTestRequest(upstream.URL, true) + respAny, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(requestBody)) + if err != nil { + results <- result{err: err} + return + } + resp := respAny.(*http.Response) + defer resp.Body.Close() + results <- result{status: resp.StatusCode} + }() + } + + for range 2 { + select { + case result := <-results: + require.NoError(t, result.err) + assert.Equal(t, http.StatusOK, result.status) + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for concurrent requests") + } + } + assert.Equal(t, int32(2), initialCalls.Load()) + assert.Equal(t, int32(1), usageCalls.Load()) + assert.Equal(t, int32(1), creditCalls.Load()) + assert.Equal(t, int32(1), resetCalls.Load()) +} + +func TestAutoResetUsageRedisLockExpiresAfterFifteenMinutes(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + redisServer := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + setAutoResetTestRedis(t, true, client) + + var responseCalls, usageCalls, resetCalls int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/backend-api/codex/responses": + responseCalls++ + if responseCalls == 2 { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusTooManyRequests) + case "/backend-api/wham/usage": + usageCalls++ + _, _ = w.Write([]byte(`{"rate_limit":{"secondary_window":{"used_percent":100,"limit_window_seconds":604800}}}`)) + case "/backend-api/wham/rate-limit-reset-credits": + _, _ = w.Write([]byte(`{"available_count":1}`)) + case "/backend-api/wham/rate-limit-reset-credits/consume": + resetCalls++ + _, _ = w.Write([]byte(`{"windows_reset":1}`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + for _, expectedStatus := range []int{http.StatusOK, http.StatusTooManyRequests} { + c, info, requestBody := newAutoResetTestRequest(upstream.URL, true) + respAny, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(requestBody)) + require.NoError(t, err) + resp := respAny.(*http.Response) + assert.Equal(t, expectedStatus, resp.StatusCode) + _ = resp.Body.Close() + } + + lockKey := "codex:auto-reset:lock:" + codexAutoResetKey(upstream.URL, "test-account") + assert.True(t, redisServer.Exists(lockKey)) + assert.Equal(t, codexAutoResetLockTTL, redisServer.TTL(lockKey)) + assert.Equal(t, 3, responseCalls) + assert.Equal(t, 1, usageCalls) + assert.Equal(t, 1, resetCalls) +} + +func TestAutoResetUsageFallsBackWhenRedisIsUnavailable(t *testing.T) { + gin.SetMode(gin.TestMode) + service.InitHttpClient() + client := redis.NewClient(&redis.Options{ + Addr: "127.0.0.1:1", + DialTimeout: 10 * time.Millisecond, + ReadTimeout: 10 * time.Millisecond, + WriteTimeout: 10 * time.Millisecond, + MaxRetries: -1, + }) + t.Cleanup(func() { _ = client.Close() }) + setAutoResetTestRedis(t, true, client) + + var resetCalls int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/backend-api/wham/usage": + _, _ = w.Write([]byte(`{"rate_limit":{"secondary_window":{"used_percent":100,"limit_window_seconds":604800}}}`)) + case "/backend-api/wham/rate-limit-reset-credits": + _, _ = w.Write([]byte(`{"available_count":1}`)) + case "/backend-api/wham/rate-limit-reset-credits/consume": + resetCalls++ + _, _ = w.Write([]byte(`{"windows_reset":1}`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + c, info, _ := newAutoResetTestRequest(upstream.URL, true) + assert.True(t, consumeCodexResetCredit(c, info)) + assert.Equal(t, 1, resetCalls) +} diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index f1bef8d60463..01feeb952668 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -288,6 +288,7 @@ const SENSITIVE_FORM_FIELDS = [ 'system_prompt_override', 'allow_service_tier', 'disable_store', + 'auto_reset_usage_enabled', 'allow_safety_identifier', 'allow_include_obfuscation', 'allow_inference_geo', @@ -338,6 +339,7 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.thinking_to_content || values.pass_through_body_enabled || values.system_prompt_override || + (values.type === 57 && values.auto_reset_usage_enabled) || values.claude_beta_query || values.upstream_model_update_check_enabled || values.upstream_model_update_auto_sync_enabled || @@ -748,6 +750,7 @@ export function ChannelMutateDrawer({ const currentSystemPromptOverride = form.watch('system_prompt_override') const currentAllowServiceTier = form.watch('allow_service_tier') const currentDisableStore = form.watch('disable_store') + const currentAutoResetUsageEnabled = form.watch('auto_reset_usage_enabled') const currentAllowSafetyIdentifier = form.watch('allow_safety_identifier') const currentAllowIncludeObfuscation = form.watch('allow_include_obfuscation') const currentAllowInferenceGeo = form.watch('allow_inference_geo') @@ -1011,6 +1014,7 @@ export function ChannelMutateDrawer({ currentThinkingToContent || currentPassThroughBodyEnabled || currentDisableTaskPollingSleep || + (currentType === 57 && currentAutoResetUsageEnabled) || currentProxy?.trim() || currentSystemPrompt?.trim() || currentSystemPromptOverride @@ -4174,6 +4178,33 @@ export function ChannelMutateDrawer({ )} /> + + {currentType === 57 && ( + ( + +
+ + {t('Auto reset usage')} + + + {t( + 'Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.' + )} + +
+ + + +
+ )} + /> + )} >([ 'system_prompt_override', 'allow_service_tier', 'disable_store', + 'auto_reset_usage_enabled', 'allow_safety_identifier', 'allow_include_obfuscation', 'allow_inference_geo', diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts index 772b6be411ac..72faaf88c524 100644 --- a/web/src/features/channels/lib/channel-form.ts +++ b/web/src/features/channels/lib/channel-form.ts @@ -200,6 +200,7 @@ export const channelFormSchema = z // Field passthrough controls (stored in settings JSON) allow_service_tier: z.boolean().optional(), // OpenAI/Anthropic disable_store: z.boolean().optional(), // OpenAI only + auto_reset_usage_enabled: z.boolean().optional(), // ChatGPT/Codex only allow_safety_identifier: z.boolean().optional(), // OpenAI only allow_include_obfuscation: z.boolean().optional(), // OpenAI: include usage obfuscation allow_inference_geo: z.boolean().optional(), // OpenAI/Anthropic: inference geography @@ -350,6 +351,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = { // Field passthrough controls allow_service_tier: false, disable_store: false, + auto_reset_usage_enabled: false, allow_safety_identifier: false, allow_include_obfuscation: false, allow_inference_geo: false, @@ -406,6 +408,7 @@ export function transformChannelToFormDefaults( let awsKeyType: 'ak_sk' | 'api_key' = 'ak_sk' let allowServiceTier = false let disableStore = false + let autoResetUsageEnabled = false let allowSafetyIdentifier = false let allowIncludeObfuscation = false let allowInferenceGeo = false @@ -426,6 +429,7 @@ export function transformChannelToFormDefaults( awsKeyType = parsed.aws_key_type || 'ak_sk' allowServiceTier = parsed.allow_service_tier === true disableStore = parsed.disable_store === true + autoResetUsageEnabled = parsed.auto_reset_usage_enabled === true allowSafetyIdentifier = parsed.allow_safety_identifier === true allowIncludeObfuscation = parsed.allow_include_obfuscation === true allowInferenceGeo = parsed.allow_inference_geo === true @@ -485,6 +489,7 @@ export function transformChannelToFormDefaults( aws_key_type: awsKeyType, allow_service_tier: allowServiceTier, disable_store: disableStore, + auto_reset_usage_enabled: autoResetUsageEnabled, allow_include_obfuscation: allowIncludeObfuscation, allow_inference_geo: allowInferenceGeo, allow_speed: allowSpeed, @@ -588,6 +593,13 @@ function buildSettingsJSON(formData: ChannelFormValues): string { } } + if (formData.type === 57) { + settingsObj.auto_reset_usage_enabled = + formData.auto_reset_usage_enabled === true + } else if ('auto_reset_usage_enabled' in settingsObj) { + delete settingsObj.auto_reset_usage_enabled + } + // Anthropic (type 14): claude_beta_query, allow_inference_geo, allow_speed if (formData.type === 14) { settingsObj.allow_inference_geo = formData.allow_inference_geo === true diff --git a/web/src/features/channels/types.ts b/web/src/features/channels/types.ts index 96bd5b2cfd12..654100bf87dd 100644 --- a/web/src/features/channels/types.ts +++ b/web/src/features/channels/types.ts @@ -95,6 +95,7 @@ export interface ChannelOtherSettings { aws_key_type?: 'ak_sk' | 'api_key' allow_service_tier?: boolean disable_store?: boolean + auto_reset_usage_enabled?: boolean allow_safety_identifier?: boolean allow_include_obfuscation?: boolean allow_inference_geo?: boolean diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0bda9d688c8e..a35763a2a81f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -504,6 +504,7 @@ "Auto group behavior": "Auto group behavior", "Auto Group Chain": "Auto Group Chain", "Auto refresh": "Auto refresh", + "Auto reset usage": "Auto reset usage", "Auto Sync Upstream Models": "Auto Sync Upstream Models", "Auto-disable rules": "Auto-disable rules", "Auto-disable status codes": "Auto-disable status codes", @@ -4908,6 +4909,7 @@ "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.", "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.", "Use external tools to extend capabilities": "Use external tools to extend capabilities", + "Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.": "Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Use one available reset credit for this channel. The reset request is sent only after confirmation.", "Use one available reset credit to refresh the current Codex usage windows.": "Use one available reset credit to refresh the current Codex usage windows.", "Use our unified OpenAI-compatible endpoint in your applications": "Use our unified OpenAI-compatible endpoint in your applications", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 668d39220efd..659e62b1ab6a 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -504,6 +504,7 @@ "Auto group behavior": "Comportement du groupe auto", "Auto Group Chain": "Chaîne de groupes automatique", "Auto refresh": "Actualisation automatique", + "Auto reset usage": "Réinitialiser automatiquement l’utilisation", "Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont", "Auto-disable rules": "Règles de désactivation automatique", "Auto-disable status codes": "Codes de statut de désactivation auto", @@ -4908,6 +4909,7 @@ "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Utilisez les noms exacts des modèles client, séparés par des virgules. Les préfixes et jokers ne sont pas pris en charge.", "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Utilisez des noms de modèle exacts comme gpt-4o, ou des règles regex préfixées par re: comme re:^gemini-.", "Use external tools to extend capabilities": "Utiliser des outils externes pour étendre les capacités", + "Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.": "Utilise un crédit de réinitialisation disponible et réessaie une fois lorsqu’un canal pris en charge renvoie une limite de débit. Seuls les canaux pris en charge sont concernés.", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Utilise un crédit de réinitialisation disponible pour ce canal. La demande n’est envoyée qu’après confirmation.", "Use one available reset credit to refresh the current Codex usage windows.": "Utilise un crédit de réinitialisation disponible pour actualiser les fenêtres d’utilisation Codex actuelles.", "Use our unified OpenAI-compatible endpoint in your applications": "Utilisez notre point de terminaison unifié compatible OpenAI dans vos applications", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index ffb10733c426..42de96c921af 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -504,6 +504,7 @@ "Auto group behavior": "auto グループの動作", "Auto Group Chain": "自動グループチェーン", "Auto refresh": "自動更新", + "Auto reset usage": "使用量を自動リセット", "Auto Sync Upstream Models": "アップストリームモデルの自動同期", "Auto-disable rules": "自動無効化ルール", "Auto-disable status codes": "自動無効化するステータスコード", @@ -4908,6 +4909,7 @@ "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "クライアントの正確なモデル名をカンマ区切りで入力します。プレフィックスやワイルドカードは使えません。", "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "gpt-4o のような完全一致のモデル名、または re:^gemini- のように re: で始まる正規表現ルールを使えます。", "Use external tools to extend capabilities": "外部ツールを利用して機能を拡張", + "Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.": "対応チャンネルがレート制限を返した場合、利用可能なリセット回数を1回使用して一度だけ再試行します。対応チャンネルでのみ有効です。", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "このチャンネルで利用可能なリセット回数を1回使用します。確認後にのみリセット要求を送信します。", "Use one available reset credit to refresh the current Codex usage windows.": "利用可能なリセット回数を1回使用して、現在の Codex 使用量ウィンドウを更新します。", "Use our unified OpenAI-compatible endpoint in your applications": "アプリケーションでOpenAI互換の統一エンドポイントを使用", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 70252a1c2442..d3418e94af33 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -504,6 +504,7 @@ "Auto group behavior": "Поведение группы auto", "Auto Group Chain": "Автоматическая цепочка групп", "Auto refresh": "Автообновление", + "Auto reset usage": "Автоматически сбрасывать использование", "Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера", "Auto-disable rules": "Правила автоотключения", "Auto-disable status codes": "Коды автоотключения", @@ -4908,6 +4909,7 @@ "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Укажите точные имена моделей клиента через запятую. Префиксы и подстановочные знаки не поддерживаются.", "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Используйте точные имена моделей, например gpt-4o, или regex-правила с префиксом re:, например re:^gemini-.", "Use external tools to extend capabilities": "Использовать внешние инструменты для расширения возможностей", + "Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.": "Использует один доступный сброс и повторяет попытку один раз, когда поддерживаемый канал возвращает ограничение частоты. Действует только для поддерживаемых каналов.", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Для этого канала будет использован один доступный сброс. Запрос отправляется только после подтверждения.", "Use one available reset credit to refresh the current Codex usage windows.": "Использует один доступный сброс, чтобы обновить текущие окна использования Codex.", "Use our unified OpenAI-compatible endpoint in your applications": "Используйте наш единый OpenAI-совместимый эндпоинт в ваших приложениях", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 8984904620db..d76e1acd3621 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -504,6 +504,7 @@ "Auto group behavior": "Cách hoạt động của nhóm auto", "Auto Group Chain": "Chuỗi nhóm tự động", "Auto refresh": "Tự động làm mới", + "Auto reset usage": "Tự động đặt lại mức sử dụng", "Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn", "Auto-disable rules": "Quy tắc tự động tắt", "Auto-disable status codes": "Mã trạng thái tự tắt", @@ -4908,6 +4909,7 @@ "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Nhập tên model chính xác từ yêu cầu client, ngăn cách bằng dấu phẩy. Không hỗ trợ tiền tố hoặc ký tự đại diện.", "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Dùng tên model chính xác như gpt-4o, hoặc quy tắc regex có tiền tố re: như re:^gemini-.", "Use external tools to extend capabilities": "Sử dụng công cụ ngoài để mở rộng khả năng", + "Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.": "Dùng một lượt đặt lại còn khả dụng và thử lại một lần khi kênh được hỗ trợ trả về giới hạn tốc độ. Chỉ áp dụng cho các kênh được hỗ trợ.", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Sử dụng một lượt đặt lại khả dụng cho kênh này. Yêu cầu chỉ được gửi sau khi xác nhận.", "Use one available reset credit to refresh the current Codex usage windows.": "Sử dụng một lượt đặt lại khả dụng để làm mới các cửa sổ mức dùng Codex hiện tại.", "Use our unified OpenAI-compatible endpoint in your applications": "Sử dụng endpoint thống nhất tương thích OpenAI trong ứng dụng của bạn", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index e9b6c9e78baf..00c93a2e7c79 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -504,6 +504,7 @@ "Auto group behavior": "自動分組行為", "Auto Group Chain": "自動分組鏈", "Auto refresh": "自動重新整理", + "Auto reset usage": "自動重置用量", "Auto Sync Upstream Models": "自動同步上游模型", "Auto-disable rules": "自動停用規則", "Auto-disable status codes": "自動停用狀態碼", @@ -4908,6 +4909,7 @@ "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "填寫客戶端請求裡的精確 model 名,多個用英文逗號分隔。不支援前綴或萬用字元。", "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "可以填寫 gpt-4o 這類精確模型名,也可以填寫 re:^gemini- 這類以 re: 開頭的正則規則。", "Use external tools to extend capabilities": "透過外部工具擴展能力", + "Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.": "當受支援的渠道返回速率限制時,使用一次可用的重置次數並重試一次。僅對受支援的渠道生效。", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "將為目前渠道使用 1 次可用重置次數。只有確認後才會發送重置請求。", "Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次數,重新整理目前 Codex 用量窗口。", "Use our unified OpenAI-compatible endpoint in your applications": "在套用中使用我們兼容 OpenAI 的統一接口", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b0309e6866be..eb9efab58e04 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -504,6 +504,7 @@ "Auto group behavior": "自动分组行为", "Auto Group Chain": "自动分组链", "Auto refresh": "自动刷新", + "Auto reset usage": "自动重置用量", "Auto Sync Upstream Models": "自动同步上游模型", "Auto-disable rules": "自动禁用规则", "Auto-disable status codes": "自动禁用状态码", @@ -4908,6 +4909,7 @@ "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "填写客户端请求里的精确 model 名,多个用英文逗号分隔。不支持前缀或通配符。", "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "可以填写 gpt-4o 这类精确模型名,也可以填写 re:^gemini- 这类以 re: 开头的正则规则。", "Use external tools to extend capabilities": "通过外部工具扩展能力", + "Use one available reset credit and retry once when a supported channel returns a rate limit. Only supported channels are affected.": "当受支持的渠道返回速率限制时,使用一次可用的重置次数并重试一次。仅支持的渠道会生效。", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "将为当前渠道使用 1 次可用重置次数。只有确认后才会发送重置请求。", "Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次数,刷新当前 Codex 用量窗口。", "Use our unified OpenAI-compatible endpoint in your applications": "在应用中使用我们兼容 OpenAI 的统一接口",