Selaa lähdekoodia

Merge branch 'main' into pr/Bliod-Cook/2610

Bliod-Cook 1 kuukausi sitten
vanhempi
commit
d77bb794d0
92 muutettua tiedostoa jossa 4818 lisäystä ja 306 poistoa
  1. 3 0
      .env.example
  2. 2 0
      common/api_type.go
  3. 4 0
      common/constants.go
  4. 11 0
      common/init.go
  5. 1 1
      common/utils.go
  6. 1 0
      constant/api_type.go
  7. 3 0
      constant/channel.go
  8. 23 1
      controller/channel-test.go
  9. 100 3
      controller/channel.go
  10. 243 0
      controller/codex_oauth.go
  11. 124 0
      controller/codex_usage.go
  12. 15 2
      controller/model_sync.go
  13. 19 0
      controller/option.go
  14. 4 0
      controller/ratio_sync.go
  15. 8 23
      controller/relay.go
  16. 5 1
      dto/error.go
  17. 82 0
      dto/gemini.go
  18. 2 2
      dto/openai_request.go
  19. 14 7
      dto/openai_response.go
  20. 55 0
      dto/values.go
  21. 3 0
      main.go
  22. 3 2
      middleware/auth.go
  23. 2 2
      middleware/distributor.go
  24. 3 2
      middleware/utils.go
  25. 2 1
      model/log.go
  26. 6 0
      model/option.go
  27. 40 11
      relay/channel/ali/adaptor.go
  28. 18 12
      relay/channel/api_request.go
  29. 164 0
      relay/channel/codex/adaptor.go
  30. 9 0
      relay/channel/codex/constants.go
  31. 30 0
      relay/channel/codex/oauth_key.go
  32. 237 70
      relay/channel/gemini/relay-gemini.go
  33. 3 0
      relay/channel/minimax/constants.go
  34. 17 2
      relay/channel/openai/adaptor.go
  35. 369 0
      relay/channel/openai/chat_via_responses.go
  36. 75 17
      relay/channel/task/doubao/adaptor.go
  37. 1 0
      relay/channel/task/doubao/constants.go
  38. 4 2
      relay/channel/task/jimeng/adaptor.go
  39. 162 0
      relay/chat_completions_via_responses.go
  40. 1 0
      relay/claude_handler.go
  41. 14 8
      relay/common/override.go
  42. 65 14
      relay/common/relay_info.go
  43. 40 0
      relay/common/request_conversion.go
  44. 44 3
      relay/compatible_handler.go
  45. 1 0
      relay/embedding_handler.go
  46. 1 0
      relay/gemini_handler.go
  47. 1 0
      relay/image_handler.go
  48. 4 1
      relay/relay_adaptor.go
  49. 1 0
      relay/rerank_handler.go
  50. 1 0
      relay/responses_handler.go
  51. 6 0
      router/api-router.go
  52. 4 1
      service/channel.go
  53. 104 0
      service/codex_credential_refresh.go
  54. 140 0
      service/codex_credential_refresh_task.go
  55. 288 0
      service/codex_oauth.go
  56. 56 0
      service/codex_wham_usage.go
  57. 24 15
      service/http_client.go
  58. 29 0
      service/log_info_generate.go
  59. 18 0
      service/openai_chat_responses_compat.go
  60. 14 0
      service/openai_chat_responses_mode.go
  61. 356 0
      service/openaicompat/chat_to_responses.go
  62. 18 0
      service/openaicompat/policy.go
  63. 33 0
      service/openaicompat/regex.go
  64. 133 0
      service/openaicompat/responses_to_chat.go
  65. 28 2
      setting/model_setting/global.go
  66. 50 0
      setting/model_setting/qwen.go
  67. 184 0
      setting/operation_setting/status_code_ranges.go
  68. 79 0
      setting/operation_setting/status_code_ranges_test.go
  69. 28 0
      types/error.go
  70. 15 7
      web/src/components/auth/LoginForm.jsx
  71. 40 16
      web/src/components/auth/RegisterForm.jsx
  72. 71 0
      web/src/components/settings/HttpStatusCodeRulesInput.jsx
  73. 9 2
      web/src/components/settings/ModelSetting.jsx
  74. 2 0
      web/src/components/settings/OperationSetting.jsx
  75. 1 1
      web/src/components/settings/personal/cards/NotificationSettings.jsx
  76. 151 0
      web/src/components/table/channels/modals/CodexOAuthModal.jsx
  77. 190 0
      web/src/components/table/channels/modals/CodexUsageModal.jsx
  78. 263 70
      web/src/components/table/channels/modals/EditChannelModal.jsx
  79. 5 0
      web/src/constants/channel.constants.js
  80. 1 0
      web/src/helpers/index.js
  81. 1 0
      web/src/helpers/render.jsx
  82. 96 0
      web/src/helpers/statusCodeRules.js
  83. 27 0
      web/src/hooks/channels/useChannelsData.jsx
  84. 16 0
      web/src/hooks/usage-logs/useUsageLogsData.jsx
  85. 14 0
      web/src/i18n/locales/en.json
  86. 5 0
      web/src/i18n/locales/fr.json
  87. 5 0
      web/src/i18n/locales/ja.json
  88. 5 0
      web/src/i18n/locales/ru.json
  89. 5 0
      web/src/i18n/locales/vi.json
  90. 14 0
      web/src/i18n/locales/zh.json
  91. 186 2
      web/src/pages/Setting/Model/SettingGlobalModel.jsx
  92. 64 3
      web/src/pages/Setting/Operation/SettingsMonitoring.jsx

+ 3 - 0
.env.example

@@ -57,6 +57,9 @@
 # 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
 # STREAMING_TIMEOUT=300
 
+# TLS / HTTP 跳过验证设置
+# TLS_INSECURE_SKIP_VERIFY=false
+
 # Gemini 识别图片 最大图片数量
 # GEMINI_VISION_MAX_IMAGE_NUM=16
 

+ 2 - 0
common/api_type.go

@@ -73,6 +73,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
 		apiType = constant.APITypeMiniMax
 	case constant.ChannelTypeReplicate:
 		apiType = constant.APITypeReplicate
+	case constant.ChannelTypeCodex:
+		apiType = constant.APITypeCodex
 	}
 	if apiType == -1 {
 		return constant.APITypeOpenAI, false

+ 4 - 0
common/constants.go

@@ -1,6 +1,7 @@
 package common
 
 import (
+	"crypto/tls"
 	//"os"
 	//"strconv"
 	"sync"
@@ -73,6 +74,9 @@ var MemoryCacheEnabled bool
 
 var LogConsumeEnabled = true
 
+var TLSInsecureSkipVerify bool
+var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
+
 var SMTPServer = ""
 var SMTPPort = 587
 var SMTPSSLEnabled = false

+ 11 - 0
common/init.go

@@ -4,6 +4,7 @@ import (
 	"flag"
 	"fmt"
 	"log"
+	"net/http"
 	"os"
 	"path/filepath"
 	"strconv"
@@ -81,6 +82,16 @@ func InitEnv() {
 	DebugEnabled = os.Getenv("DEBUG") == "true"
 	MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
 	IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
+	TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
+	if TLSInsecureSkipVerify {
+		if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
+			if tr.TLSClientConfig != nil {
+				tr.TLSClientConfig.InsecureSkipVerify = true
+			} else {
+				tr.TLSClientConfig = InsecureTLSConfig
+			}
+		}
+	}
 
 	// Parse requestInterval and set RequestInterval
 	requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL"))

+ 1 - 1
common/utils.go

@@ -263,7 +263,7 @@ func GetTimestamp() int64 {
 }
 
 func GetTimeString() string {
-	now := time.Now()
+	now := time.Now().UTC()
 	return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9)
 }
 

+ 1 - 0
constant/api_type.go

@@ -35,5 +35,6 @@ const (
 	APITypeSubmodel
 	APITypeMiniMax
 	APITypeReplicate
+	APITypeCodex
 	APITypeDummy // this one is only for count, do not add any channel after this
 )

+ 3 - 0
constant/channel.go

@@ -54,6 +54,7 @@ const (
 	ChannelTypeDoubaoVideo    = 54
 	ChannelTypeSora           = 55
 	ChannelTypeReplicate      = 56
+	ChannelTypeCodex          = 57
 	ChannelTypeDummy          // this one is only for count, do not add any channel after this
 
 )
@@ -116,6 +117,7 @@ var ChannelBaseURLs = []string{
 	"https://ark.cn-beijing.volces.com",         //54
 	"https://api.openai.com",                    //55
 	"https://api.replicate.com",                 //56
+	"https://chatgpt.com",                       //57
 }
 
 var ChannelTypeNames = map[int]string{
@@ -172,6 +174,7 @@ var ChannelTypeNames = map[int]string{
 	ChannelTypeDoubaoVideo:    "DoubaoVideo",
 	ChannelTypeSora:           "Sora",
 	ChannelTypeReplicate:      "Replicate",
+	ChannelTypeCodex:          "Codex",
 }
 
 func GetChannelTypeName(channelType int) string {

+ 23 - 1
controller/channel-test.go

@@ -193,6 +193,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
 		}
 	}
 
+	info.IsChannelTest = true
 	info.InitChannelMeta(c)
 
 	err = helper.ModelMappedHelper(c, info, request)
@@ -309,8 +310,29 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
 			newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed),
 		}
 	}
+
+	//jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
+	//if err != nil {
+	//	return testResult{
+	//		context:     c,
+	//		localErr:    err,
+	//		newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
+	//	}
+	//}
+
+	if len(info.ParamOverride) > 0 {
+		jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
+		if err != nil {
+			return testResult{
+				context:     c,
+				localErr:    err,
+				newAPIError: types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid),
+			}
+		}
+	}
+
 	requestBody := bytes.NewBuffer(jsonData)
-	c.Request.Body = io.NopCloser(requestBody)
+	c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
 	resp, err := adaptor.DoRequest(c, info, requestBody)
 	if err != nil {
 		return testResult{

+ 100 - 3
controller/channel.go

@@ -1,16 +1,19 @@
 package controller
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"net/http"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/constant"
 	"github.com/QuantumNous/new-api/dto"
 	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/relay/channel/gemini"
 	"github.com/QuantumNous/new-api/relay/channel/ollama"
 	"github.com/QuantumNous/new-api/service"
 
@@ -260,11 +263,37 @@ func FetchUpstreamModels(c *gin.Context) {
 		return
 	}
 
+	// 对于 Gemini 渠道,使用特殊处理
+	if channel.Type == constant.ChannelTypeGemini {
+		// 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥)
+		key, _, apiErr := channel.GetNextEnabledKey()
+		if apiErr != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()),
+			})
+			return
+		}
+		key = strings.TrimSpace(key)
+		models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()),
+			})
+			return
+		}
+
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "",
+			"data":    models,
+		})
+		return
+	}
+
 	var url string
 	switch channel.Type {
-	case constant.ChannelTypeGemini:
-		// curl https://example.com/v1beta/models?key=$GEMINI_API_KEY
-		url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
 	case constant.ChannelTypeAli:
 		url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
 	case constant.ChannelTypeZhipu_v4:
@@ -577,9 +606,60 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
 		}
 	}
 
+	// Codex OAuth key validation (optional, only when JSON object is provided)
+	if channel.Type == constant.ChannelTypeCodex {
+		trimmedKey := strings.TrimSpace(channel.Key)
+		if isAdd || trimmedKey != "" {
+			if !strings.HasPrefix(trimmedKey, "{") {
+				return fmt.Errorf("Codex key must be a valid JSON object")
+			}
+			var keyMap map[string]any
+			if err := common.Unmarshal([]byte(trimmedKey), &keyMap); err != nil {
+				return fmt.Errorf("Codex key must be a valid JSON object")
+			}
+			if v, ok := keyMap["access_token"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" {
+				return fmt.Errorf("Codex key JSON must include access_token")
+			}
+			if v, ok := keyMap["account_id"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" {
+				return fmt.Errorf("Codex key JSON must include account_id")
+			}
+		}
+	}
+
 	return nil
 }
 
+func RefreshCodexChannelCredential(c *gin.Context) {
+	channelId, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		common.ApiError(c, fmt.Errorf("invalid channel id: %w", err))
+		return
+	}
+
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
+	defer cancel()
+
+	oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "refreshed",
+		"data": gin.H{
+			"expires_at":   oauthKey.Expired,
+			"last_refresh": oauthKey.LastRefresh,
+			"account_id":   oauthKey.AccountID,
+			"email":        oauthKey.Email,
+			"channel_id":   ch.Id,
+			"channel_type": ch.Type,
+			"channel_name": ch.Name,
+		},
+	})
+}
+
 type AddChannelRequest struct {
 	Mode                      string                `json:"mode"`
 	MultiKeyMode              constant.MultiKeyMode `json:"multi_key_mode"`
@@ -1072,6 +1152,23 @@ func FetchModels(c *gin.Context) {
 		return
 	}
 
+	if req.Type == constant.ChannelTypeGemini {
+		models, err := gemini.FetchGeminiModels(baseURL, key, "")
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()),
+			})
+			return
+		}
+
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"data":    models,
+		})
+		return
+	}
+
 	client := &http.Client{}
 	url := fmt.Sprintf("%s/v1/models", baseURL)
 

+ 243 - 0
controller/codex_oauth.go

@@ -0,0 +1,243 @@
+package controller
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/relay/channel/codex"
+	"github.com/QuantumNous/new-api/service"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+type codexOAuthCompleteRequest struct {
+	Input string `json:"input"`
+}
+
+func codexOAuthSessionKey(channelID int, field string) string {
+	return fmt.Sprintf("codex_oauth_%s_%d", field, channelID)
+}
+
+func parseCodexAuthorizationInput(input string) (code string, state string, err error) {
+	v := strings.TrimSpace(input)
+	if v == "" {
+		return "", "", errors.New("empty input")
+	}
+	if strings.Contains(v, "#") {
+		parts := strings.SplitN(v, "#", 2)
+		code = strings.TrimSpace(parts[0])
+		state = strings.TrimSpace(parts[1])
+		return code, state, nil
+	}
+	if strings.Contains(v, "code=") {
+		u, parseErr := url.Parse(v)
+		if parseErr == nil {
+			q := u.Query()
+			code = strings.TrimSpace(q.Get("code"))
+			state = strings.TrimSpace(q.Get("state"))
+			return code, state, nil
+		}
+		q, parseErr := url.ParseQuery(v)
+		if parseErr == nil {
+			code = strings.TrimSpace(q.Get("code"))
+			state = strings.TrimSpace(q.Get("state"))
+			return code, state, nil
+		}
+	}
+
+	code = v
+	return code, "", nil
+}
+
+func StartCodexOAuth(c *gin.Context) {
+	startCodexOAuthWithChannelID(c, 0)
+}
+
+func StartCodexOAuthForChannel(c *gin.Context) {
+	channelID, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		common.ApiError(c, fmt.Errorf("invalid channel id: %w", err))
+		return
+	}
+	startCodexOAuthWithChannelID(c, channelID)
+}
+
+func startCodexOAuthWithChannelID(c *gin.Context, channelID int) {
+	if channelID > 0 {
+		ch, err := model.GetChannelById(channelID, false)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+		if ch == nil {
+			c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"})
+			return
+		}
+		if ch.Type != constant.ChannelTypeCodex {
+			c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"})
+			return
+		}
+	}
+
+	flow, err := service.CreateCodexOAuthAuthorizationFlow()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	session := sessions.Default(c)
+	session.Set(codexOAuthSessionKey(channelID, "state"), flow.State)
+	session.Set(codexOAuthSessionKey(channelID, "verifier"), flow.Verifier)
+	session.Set(codexOAuthSessionKey(channelID, "created_at"), time.Now().Unix())
+	_ = session.Save()
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data": gin.H{
+			"authorize_url": flow.AuthorizeURL,
+		},
+	})
+}
+
+func CompleteCodexOAuth(c *gin.Context) {
+	completeCodexOAuthWithChannelID(c, 0)
+}
+
+func CompleteCodexOAuthForChannel(c *gin.Context) {
+	channelID, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		common.ApiError(c, fmt.Errorf("invalid channel id: %w", err))
+		return
+	}
+	completeCodexOAuthWithChannelID(c, channelID)
+}
+
+func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) {
+	req := codexOAuthCompleteRequest{}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	code, state, err := parseCodexAuthorizationInput(req.Input)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+	if strings.TrimSpace(code) == "" {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "missing authorization code"})
+		return
+	}
+	if strings.TrimSpace(state) == "" {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "missing state in input"})
+		return
+	}
+
+	if channelID > 0 {
+		ch, err := model.GetChannelById(channelID, false)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+		if ch == nil {
+			c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"})
+			return
+		}
+		if ch.Type != constant.ChannelTypeCodex {
+			c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"})
+			return
+		}
+	}
+
+	session := sessions.Default(c)
+	expectedState, _ := session.Get(codexOAuthSessionKey(channelID, "state")).(string)
+	verifier, _ := session.Get(codexOAuthSessionKey(channelID, "verifier")).(string)
+	if strings.TrimSpace(expectedState) == "" || strings.TrimSpace(verifier) == "" {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "oauth flow not started or session expired"})
+		return
+	}
+	if state != expectedState {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "state mismatch"})
+		return
+	}
+
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
+	defer cancel()
+
+	tokenRes, err := service.ExchangeCodexAuthorizationCode(ctx, code, verifier)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+
+	accountID, ok := service.ExtractCodexAccountIDFromJWT(tokenRes.AccessToken)
+	if !ok {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "failed to extract account_id from access_token"})
+		return
+	}
+	email, _ := service.ExtractEmailFromJWT(tokenRes.AccessToken)
+
+	key := codex.OAuthKey{
+		AccessToken:  tokenRes.AccessToken,
+		RefreshToken: tokenRes.RefreshToken,
+		AccountID:    accountID,
+		LastRefresh:  time.Now().Format(time.RFC3339),
+		Expired:      tokenRes.ExpiresAt.Format(time.RFC3339),
+		Email:        email,
+		Type:         "codex",
+	}
+	encoded, err := common.Marshal(key)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	session.Delete(codexOAuthSessionKey(channelID, "state"))
+	session.Delete(codexOAuthSessionKey(channelID, "verifier"))
+	session.Delete(codexOAuthSessionKey(channelID, "created_at"))
+	_ = session.Save()
+
+	if channelID > 0 {
+		if err := model.DB.Model(&model.Channel{}).Where("id = ?", channelID).Update("key", string(encoded)).Error; err != nil {
+			common.ApiError(c, err)
+			return
+		}
+		model.InitChannelCache()
+		service.ResetProxyClientCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "saved",
+			"data": gin.H{
+				"channel_id":   channelID,
+				"account_id":   accountID,
+				"email":        email,
+				"expires_at":   key.Expired,
+				"last_refresh": key.LastRefresh,
+			},
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "generated",
+		"data": gin.H{
+			"key":          string(encoded),
+			"account_id":   accountID,
+			"email":        email,
+			"expires_at":   key.Expired,
+			"last_refresh": key.LastRefresh,
+		},
+	})
+}

+ 124 - 0
controller/codex_usage.go

@@ -0,0 +1,124 @@
+package controller
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/relay/channel/codex"
+	"github.com/QuantumNous/new-api/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+func GetCodexChannelUsage(c *gin.Context) {
+	channelId, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		common.ApiError(c, fmt.Errorf("invalid channel id: %w", err))
+		return
+	}
+
+	ch, err := model.GetChannelById(channelId, true)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if ch == nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"})
+		return
+	}
+	if ch.Type != constant.ChannelTypeCodex {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"})
+		return
+	}
+	if ch.ChannelInfo.IsMultiKey {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "multi-key channel is not supported"})
+		return
+	}
+
+	oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key))
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+	accessToken := strings.TrimSpace(oauthKey.AccessToken)
+	accountID := strings.TrimSpace(oauthKey.AccountID)
+	if accessToken == "" {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: access_token is required"})
+		return
+	}
+	if accountID == "" {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: account_id is required"})
+		return
+	}
+
+	client, err := service.NewProxyHttpClient(ch.GetSetting().Proxy)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
+	defer cancel()
+
+	statusCode, body, err := service.FetchCodexWhamUsage(ctx, client, ch.GetBaseURL(), accessToken, accountID)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+
+	if (statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden) && strings.TrimSpace(oauthKey.RefreshToken) != "" {
+		refreshCtx, refreshCancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
+		defer refreshCancel()
+
+		res, refreshErr := service.RefreshCodexOAuthToken(refreshCtx, oauthKey.RefreshToken)
+		if refreshErr == nil {
+			oauthKey.AccessToken = res.AccessToken
+			oauthKey.RefreshToken = res.RefreshToken
+			oauthKey.LastRefresh = time.Now().Format(time.RFC3339)
+			oauthKey.Expired = res.ExpiresAt.Format(time.RFC3339)
+			if strings.TrimSpace(oauthKey.Type) == "" {
+				oauthKey.Type = "codex"
+			}
+
+			encoded, encErr := common.Marshal(oauthKey)
+			if encErr == nil {
+				_ = model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error
+				model.InitChannelCache()
+				service.ResetProxyClientCache()
+			}
+
+			ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second)
+			defer cancel2()
+			statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID)
+			if err != nil {
+				c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+				return
+			}
+		}
+	}
+
+	var payload any
+	if json.Unmarshal(body, &payload) != nil {
+		payload = string(body)
+	}
+
+	ok := statusCode >= 200 && statusCode < 300
+	resp := gin.H{
+		"success":         ok,
+		"message":         "",
+		"upstream_status": statusCode,
+		"data":            payload,
+	}
+	if !ok {
+		resp["message"] = fmt.Sprintf("upstream status: %d", statusCode)
+	}
+	c.JSON(http.StatusOK, resp)
+}

+ 15 - 2
controller/model_sync.go

@@ -99,6 +99,9 @@ func newHTTPClient() *http.Client {
 		ExpectContinueTimeout: 1 * time.Second,
 		ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
 	}
+	if common.TLSInsecureSkipVerify {
+		transport.TLSClientConfig = common.InsecureTLSConfig
+	}
 	transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
 		host, _, err := net.SplitHostPort(addr)
 		if err != nil {
@@ -115,7 +118,17 @@ func newHTTPClient() *http.Client {
 	return &http.Client{Transport: transport}
 }
 
-var httpClient = newHTTPClient()
+var (
+	httpClientOnce sync.Once
+	httpClient     *http.Client
+)
+
+func getHTTPClient() *http.Client {
+	httpClientOnce.Do(func() {
+		httpClient = newHTTPClient()
+	})
+	return httpClient
+}
 
 func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
 	var lastErr error
@@ -138,7 +151,7 @@ func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T])
 		}
 		cacheMutex.RUnlock()
 
-		resp, err := httpClient.Do(req)
+		resp, err := getHTTPClient().Do(req)
 		if err != nil {
 			lastErr = err
 			// backoff with jitter

+ 19 - 0
controller/option.go

@@ -10,6 +10,7 @@ import (
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/setting"
 	"github.com/QuantumNous/new-api/setting/console_setting"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
 	"github.com/QuantumNous/new-api/setting/ratio_setting"
 	"github.com/QuantumNous/new-api/setting/system_setting"
 
@@ -177,6 +178,24 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
+	case "AutomaticDisableStatusCodes":
+		_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	case "AutomaticRetryStatusCodes":
+		_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
 	case "console_setting.api_info":
 		err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
 		if err != nil {

+ 4 - 0
controller/ratio_sync.go

@@ -11,6 +11,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/logger"
 
 	"github.com/QuantumNous/new-api/dto"
@@ -110,6 +111,9 @@ func FetchUpstreamRatios(c *gin.Context) {
 
 	dialer := &net.Dialer{Timeout: 10 * time.Second}
 	transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
+	if common.TLSInsecureSkipVerify {
+		transport.TLSClientConfig = common.InsecureTLSConfig
+	}
 	transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
 		host, _, err := net.SplitHostPort(addr)
 		if err != nil {

+ 8 - 23
controller/relay.go

@@ -21,6 +21,7 @@ import (
 	"github.com/QuantumNous/new-api/relay/helper"
 	"github.com/QuantumNous/new-api/service"
 	"github.com/QuantumNous/new-api/setting"
+	"github.com/QuantumNous/new-api/setting/operation_setting"
 	"github.com/QuantumNous/new-api/types"
 
 	"github.com/bytedance/gopkg/util/gopool"
@@ -316,30 +317,14 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
 	if _, ok := c.Get("specific_channel_id"); ok {
 		return false
 	}
-	if openaiErr.StatusCode == http.StatusTooManyRequests {
-		return true
-	}
-	if openaiErr.StatusCode == 307 {
-		return true
-	}
-	if openaiErr.StatusCode/100 == 5 {
-		// 超时不重试
-		if openaiErr.StatusCode == 504 || openaiErr.StatusCode == 524 {
-			return false
-		}
-		return true
-	}
-	if openaiErr.StatusCode == http.StatusBadRequest {
-		return false
-	}
-	if openaiErr.StatusCode == 408 {
-		// azure处理超时不重试
+	code := openaiErr.StatusCode
+	if code >= 200 && code < 300 {
 		return false
 	}
-	if openaiErr.StatusCode/100 == 2 {
-		return false
+	if code < 100 || code > 599 {
+		return true
 	}
-	return true
+	return operation_setting.ShouldRetryByStatusCode(code)
 }
 
 func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
@@ -348,7 +333,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
 	// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
 	if service.ShouldDisableChannel(channelError.ChannelType, err) && channelError.AutoBan {
 		gopool.Go(func() {
-			service.DisableChannel(channelError, err.Error())
+			service.DisableChannel(channelError, err.ErrorWithStatusCode())
 		})
 	}
 
@@ -378,7 +363,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
 			adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
 		}
 		other["admin_info"] = adminInfo
-		model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
+		model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, 0, false, userGroup, other)
 	}
 
 }

+ 5 - 1
dto/error.go

@@ -26,7 +26,8 @@ type GeneralErrorResponse struct {
 	Msg      string          `json:"msg"`
 	Err      string          `json:"err"`
 	ErrorMsg string          `json:"error_msg"`
-	Metadata json.RawMessage   `json:"metadata,omitempty"`
+	Metadata json.RawMessage `json:"metadata,omitempty"`
+	Detail   string          `json:"detail,omitempty"`
 	Header   struct {
 		Message string `json:"message"`
 	} `json:"header"`
@@ -79,6 +80,9 @@ func (e GeneralErrorResponse) ToMessage() string {
 	if e.ErrorMsg != "" {
 		return e.ErrorMsg
 	}
+	if e.Detail != "" {
+		return e.Detail
+	}
 	if e.Header.Message != "" {
 		return e.Header.Message
 	}

+ 82 - 0
dto/gemini.go

@@ -341,6 +341,88 @@ type GeminiChatGenerationConfig struct {
 	ImageConfig        json.RawMessage       `json:"imageConfig,omitempty"`  // RawMessage to allow flexible image config
 }
 
+// UnmarshalJSON allows GeminiChatGenerationConfig to accept both snake_case and camelCase fields.
+func (c *GeminiChatGenerationConfig) UnmarshalJSON(data []byte) error {
+	type Alias GeminiChatGenerationConfig
+	var aux struct {
+		Alias
+		TopPSnake               float64               `json:"top_p,omitempty"`
+		TopKSnake               float64               `json:"top_k,omitempty"`
+		MaxOutputTokensSnake    uint                  `json:"max_output_tokens,omitempty"`
+		CandidateCountSnake     int                   `json:"candidate_count,omitempty"`
+		StopSequencesSnake      []string              `json:"stop_sequences,omitempty"`
+		ResponseMimeTypeSnake   string                `json:"response_mime_type,omitempty"`
+		ResponseSchemaSnake     any                   `json:"response_schema,omitempty"`
+		ResponseJsonSchemaSnake json.RawMessage       `json:"response_json_schema,omitempty"`
+		PresencePenaltySnake    *float32              `json:"presence_penalty,omitempty"`
+		FrequencyPenaltySnake   *float32              `json:"frequency_penalty,omitempty"`
+		ResponseLogprobsSnake   bool                  `json:"response_logprobs,omitempty"`
+		MediaResolutionSnake    MediaResolution       `json:"media_resolution,omitempty"`
+		ResponseModalitiesSnake []string              `json:"response_modalities,omitempty"`
+		ThinkingConfigSnake     *GeminiThinkingConfig `json:"thinking_config,omitempty"`
+		SpeechConfigSnake       json.RawMessage       `json:"speech_config,omitempty"`
+		ImageConfigSnake        json.RawMessage       `json:"image_config,omitempty"`
+	}
+
+	if err := common.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	*c = GeminiChatGenerationConfig(aux.Alias)
+
+	// Prioritize snake_case if present
+	if aux.TopPSnake != 0 {
+		c.TopP = aux.TopPSnake
+	}
+	if aux.TopKSnake != 0 {
+		c.TopK = aux.TopKSnake
+	}
+	if aux.MaxOutputTokensSnake != 0 {
+		c.MaxOutputTokens = aux.MaxOutputTokensSnake
+	}
+	if aux.CandidateCountSnake != 0 {
+		c.CandidateCount = aux.CandidateCountSnake
+	}
+	if len(aux.StopSequencesSnake) > 0 {
+		c.StopSequences = aux.StopSequencesSnake
+	}
+	if aux.ResponseMimeTypeSnake != "" {
+		c.ResponseMimeType = aux.ResponseMimeTypeSnake
+	}
+	if aux.ResponseSchemaSnake != nil {
+		c.ResponseSchema = aux.ResponseSchemaSnake
+	}
+	if len(aux.ResponseJsonSchemaSnake) > 0 {
+		c.ResponseJsonSchema = aux.ResponseJsonSchemaSnake
+	}
+	if aux.PresencePenaltySnake != nil {
+		c.PresencePenalty = aux.PresencePenaltySnake
+	}
+	if aux.FrequencyPenaltySnake != nil {
+		c.FrequencyPenalty = aux.FrequencyPenaltySnake
+	}
+	if aux.ResponseLogprobsSnake {
+		c.ResponseLogprobs = aux.ResponseLogprobsSnake
+	}
+	if aux.MediaResolutionSnake != "" {
+		c.MediaResolution = aux.MediaResolutionSnake
+	}
+	if len(aux.ResponseModalitiesSnake) > 0 {
+		c.ResponseModalities = aux.ResponseModalitiesSnake
+	}
+	if aux.ThinkingConfigSnake != nil {
+		c.ThinkingConfig = aux.ThinkingConfigSnake
+	}
+	if len(aux.SpeechConfigSnake) > 0 {
+		c.SpeechConfig = aux.SpeechConfigSnake
+	}
+	if len(aux.ImageConfigSnake) > 0 {
+		c.ImageConfig = aux.ImageConfigSnake
+	}
+
+	return nil
+}
+
 type MediaResolution string
 
 type GeminiChatCandidate struct {

+ 2 - 2
dto/openai_request.go

@@ -808,11 +808,11 @@ type OpenAIResponsesRequest struct {
 	PromptCacheKey       json.RawMessage `json:"prompt_cache_key,omitempty"`
 	PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
 	Stream               bool            `json:"stream,omitempty"`
-	Temperature          float64         `json:"temperature,omitempty"`
+	Temperature          *float64        `json:"temperature,omitempty"`
 	Text                 json.RawMessage `json:"text,omitempty"`
 	ToolChoice           json.RawMessage `json:"tool_choice,omitempty"`
 	Tools                json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
-	TopP                 float64         `json:"top_p,omitempty"`
+	TopP                 *float64        `json:"top_p,omitempty"`
 	Truncation           string          `json:"truncation,omitempty"`
 	User                 string          `json:"user,omitempty"`
 	MaxToolCalls         uint            `json:"max_tool_calls,omitempty"`

+ 14 - 7
dto/openai_response.go

@@ -334,13 +334,16 @@ type IncompleteDetails struct {
 }
 
 type ResponsesOutput struct {
-	Type    string                   `json:"type"`
-	ID      string                   `json:"id"`
-	Status  string                   `json:"status"`
-	Role    string                   `json:"role"`
-	Content []ResponsesOutputContent `json:"content"`
-	Quality string                   `json:"quality"`
-	Size    string                   `json:"size"`
+	Type      string                   `json:"type"`
+	ID        string                   `json:"id"`
+	Status    string                   `json:"status"`
+	Role      string                   `json:"role"`
+	Content   []ResponsesOutputContent `json:"content"`
+	Quality   string                   `json:"quality"`
+	Size      string                   `json:"size"`
+	CallId    string                   `json:"call_id,omitempty"`
+	Name      string                   `json:"name,omitempty"`
+	Arguments string                   `json:"arguments,omitempty"`
 }
 
 type ResponsesOutputContent struct {
@@ -369,6 +372,10 @@ type ResponsesStreamResponse struct {
 	Response *OpenAIResponsesResponse `json:"response,omitempty"`
 	Delta    string                   `json:"delta,omitempty"`
 	Item     *ResponsesOutput         `json:"item,omitempty"`
+	// - response.function_call_arguments.delta
+	// - response.function_call_arguments.done
+	OutputIndex *int   `json:"output_index,omitempty"`
+	ItemID      string `json:"item_id,omitempty"`
 }
 
 // GetOpenAIError 从动态错误类型中提取OpenAIError结构

+ 55 - 0
dto/values.go

@@ -0,0 +1,55 @@
+package dto
+
+import (
+	"encoding/json"
+	"strconv"
+)
+
+type IntValue int
+
+func (i *IntValue) UnmarshalJSON(b []byte) error {
+	var n int
+	if err := json.Unmarshal(b, &n); err == nil {
+		*i = IntValue(n)
+		return nil
+	}
+	var s string
+	if err := json.Unmarshal(b, &s); err != nil {
+		return err
+	}
+	v, err := strconv.Atoi(s)
+	if err != nil {
+		return err
+	}
+	*i = IntValue(v)
+	return nil
+}
+
+func (i IntValue) MarshalJSON() ([]byte, error) {
+	return json.Marshal(int(i))
+}
+
+type BoolValue bool
+
+func (b *BoolValue) UnmarshalJSON(data []byte) error {
+	var boolean bool
+	if err := json.Unmarshal(data, &boolean); err == nil {
+		*b = BoolValue(boolean)
+		return nil
+	}
+	var str string
+	if err := json.Unmarshal(data, &str); err != nil {
+		return err
+	}
+	if str == "true" {
+		*b = BoolValue(true)
+	} else if str == "false" {
+		*b = BoolValue(false)
+	} else {
+		return json.Unmarshal(data, &boolean)
+	}
+	return nil
+}
+func (b BoolValue) MarshalJSON() ([]byte, error) {
+	return json.Marshal(bool(b))
+}

+ 3 - 0
main.go

@@ -102,6 +102,9 @@ func main() {
 
 	go controller.AutomaticallyTestChannels()
 
+	// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
+	service.StartCodexCredentialAutoRefreshTask()
+
 	if common.IsMasterNode && constant.UpdateTask {
 		gopool.Go(func() {
 			controller.UpdateMidjourneyTaskBulk()

+ 3 - 2
middleware/auth.go

@@ -13,6 +13,7 @@ import (
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/service"
 	"github.com/QuantumNous/new-api/setting/ratio_setting"
+	"github.com/QuantumNous/new-api/types"
 
 	"github.com/gin-contrib/sessions"
 	"github.com/gin-gonic/gin"
@@ -195,7 +196,7 @@ func TokenAuth() func(c *gin.Context) {
 			}
 			c.Request.Header.Set("Authorization", "Bearer "+key)
 		}
-		// 检查path包含/v1/messages 或 /v1/models 
+		// 检查path包含/v1/messages 或 /v1/models
 		if strings.Contains(c.Request.URL.Path, "/v1/messages") || strings.Contains(c.Request.URL.Path, "/v1/models") {
 			anthropicKey := c.Request.Header.Get("x-api-key")
 			if anthropicKey != "" {
@@ -256,7 +257,7 @@ func TokenAuth() func(c *gin.Context) {
 				return
 			}
 			if common.IsIpInCIDRList(ip, allowIps) == false {
-				abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中")
+				abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中", types.ErrorCodeAccessDenied)
 				return
 			}
 			logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp)

+ 2 - 2
middleware/distributor.go

@@ -114,11 +114,11 @@ func Distribute() func(c *gin.Context) {
 					//	common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
 					//	message = "数据库一致性已被破坏,请联系管理员"
 					//}
-					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, string(types.ErrorCodeModelNotFound))
+					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, types.ErrorCodeModelNotFound)
 					return
 				}
 				if channel == nil {
-					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", usingGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound))
+					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", usingGroup, modelRequest.Model), types.ErrorCodeModelNotFound)
 					return
 				}
 			}

+ 3 - 2
middleware/utils.go

@@ -5,13 +5,14 @@ import (
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/logger"
+	"github.com/QuantumNous/new-api/types"
 	"github.com/gin-gonic/gin"
 )
 
-func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...string) {
+func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...types.ErrorCode) {
 	codeStr := ""
 	if len(code) > 0 {
-		codeStr = code[0]
+		codeStr = string(code[0])
 	}
 	userId := c.GetInt("id")
 	c.JSON(statusCode, gin.H{

+ 2 - 1
model/log.go

@@ -56,8 +56,9 @@ func formatUserLogs(logs []*Log) {
 		var otherMap map[string]interface{}
 		otherMap, _ = common.StrToMap(logs[i].Other)
 		if otherMap != nil {
-			// delete admin
+			// Remove admin-only debug fields.
 			delete(otherMap, "admin_info")
+			delete(otherMap, "request_conversion")
 		}
 		logs[i].Other = common.MapToJsonStr(otherMap)
 		logs[i].Id = logs[i].Id % 1024

+ 6 - 0
model/option.go

@@ -143,6 +143,8 @@ func InitOptionMap() {
 	common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
 	common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
 	common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
+	common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString()
+	common.OptionMap["AutomaticRetryStatusCodes"] = operation_setting.AutomaticRetryStatusCodesToString()
 	common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled())
 
 	// 自动添加所有注册的模型配置
@@ -444,6 +446,10 @@ func updateOptionMap(key string, value string) (err error) {
 		setting.SensitiveWordsFromString(value)
 	case "AutomaticDisableKeywords":
 		operation_setting.AutomaticDisableKeywordsFromString(value)
+	case "AutomaticDisableStatusCodes":
+		err = operation_setting.AutomaticDisableStatusCodesFromString(value)
+	case "AutomaticRetryStatusCodes":
+		err = operation_setting.AutomaticRetryStatusCodesFromString(value)
 	case "StreamCacheQueueLength":
 		setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
 	case "PayMethods":

+ 40 - 11
relay/channel/ali/adaptor.go

@@ -13,6 +13,8 @@ import (
 	"github.com/QuantumNous/new-api/relay/channel/openai"
 	relaycommon "github.com/QuantumNous/new-api/relay/common"
 	"github.com/QuantumNous/new-api/relay/constant"
+	"github.com/QuantumNous/new-api/setting/model_setting"
+	"github.com/QuantumNous/new-api/service"
 	"github.com/QuantumNous/new-api/types"
 
 	"github.com/gin-gonic/gin"
@@ -22,6 +24,18 @@ type Adaptor struct {
 	IsSyncImageModel bool
 }
 
+/*
+	var syncModels = []string{
+		"z-image",
+		"qwen-image",
+		"wan2.6",
+	}
+*/
+func supportsAliAnthropicMessages(modelName string) bool {
+	// Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion.
+	return strings.Contains(strings.ToLower(modelName), "qwen")
+}
+
 var syncModels = []string{
 	"z-image",
 	"qwen-image",
@@ -29,12 +43,7 @@ var syncModels = []string{
 }
 
 func isSyncImageModel(modelName string) bool {
-	for _, m := range syncModels {
-		if strings.Contains(modelName, m) {
-			return true
-		}
-	}
-	return false
+	return model_setting.IsSyncImageModel(modelName)
 }
 
 func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
@@ -43,7 +52,18 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
 }
 
 func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
-	return req, nil
+	if supportsAliAnthropicMessages(info.UpstreamModelName) {
+		return req, nil
+	}
+
+	oaiReq, err := service.ClaudeToOpenAIRequest(*req, info)
+	if err != nil {
+		return nil, err
+	}
+	if info.SupportStreamOptions && info.IsStream {
+		oaiReq.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
+	}
+	return a.ConvertOpenAIRequest(c, info, oaiReq)
 }
 
 func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -53,7 +73,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 	var fullRequestURL string
 	switch info.RelayFormat {
 	case types.RelayFormatClaude:
-		fullRequestURL = fmt.Sprintf("%s/api/v2/apps/claude-code-proxy/v1/messages", info.ChannelBaseUrl)
+		if supportsAliAnthropicMessages(info.UpstreamModelName) {
+			fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl)
+		} else {
+			fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl)
+		}
 	default:
 		switch info.RelayMode {
 		case constant.RelayModeEmbeddings:
@@ -197,11 +221,16 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
 func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
 	switch info.RelayFormat {
 	case types.RelayFormatClaude:
-		if info.IsStream {
-			return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
-		} else {
+		if supportsAliAnthropicMessages(info.UpstreamModelName) {
+			if info.IsStream {
+				return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
+			}
+
 			return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
 		}
+
+		adaptor := openai.Adaptor{}
+		return adaptor.DoResponse(c, resp, info)
 	default:
 		switch info.RelayMode {
 		case constant.RelayModeImagesGenerations:

+ 18 - 12
relay/channel/api_request.go

@@ -71,6 +71,12 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
 		return nil, fmt.Errorf("new request failed: %w", err)
 	}
 	headers := req.Header
+	err = a.SetupRequestHeader(c, &headers, info)
+	if err != nil {
+		return nil, fmt.Errorf("setup request header failed: %w", err)
+	}
+	// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
+	// 这样可以覆盖默认的 Authorization header 设置
 	headerOverride, err := processHeaderOverride(info)
 	if err != nil {
 		return nil, err
@@ -78,10 +84,6 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
 	for key, value := range headerOverride {
 		headers.Set(key, value)
 	}
-	err = a.SetupRequestHeader(c, &headers, info)
-	if err != nil {
-		return nil, fmt.Errorf("setup request header failed: %w", err)
-	}
 	resp, err := doRequest(c, req, info)
 	if err != nil {
 		return nil, fmt.Errorf("do request failed: %w", err)
@@ -104,6 +106,12 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
 	// set form data
 	req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
 	headers := req.Header
+	err = a.SetupRequestHeader(c, &headers, info)
+	if err != nil {
+		return nil, fmt.Errorf("setup request header failed: %w", err)
+	}
+	// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
+	// 这样可以覆盖默认的 Authorization header 设置
 	headerOverride, err := processHeaderOverride(info)
 	if err != nil {
 		return nil, err
@@ -111,10 +119,6 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
 	for key, value := range headerOverride {
 		headers.Set(key, value)
 	}
-	err = a.SetupRequestHeader(c, &headers, info)
-	if err != nil {
-		return nil, fmt.Errorf("setup request header failed: %w", err)
-	}
 	resp, err := doRequest(c, req, info)
 	if err != nil {
 		return nil, fmt.Errorf("do request failed: %w", err)
@@ -128,6 +132,12 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
 		return nil, fmt.Errorf("get request url failed: %w", err)
 	}
 	targetHeader := http.Header{}
+	err = a.SetupRequestHeader(c, &targetHeader, info)
+	if err != nil {
+		return nil, fmt.Errorf("setup request header failed: %w", err)
+	}
+	// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
+	// 这样可以覆盖默认的 Authorization header 设置
 	headerOverride, err := processHeaderOverride(info)
 	if err != nil {
 		return nil, err
@@ -135,10 +145,6 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
 	for key, value := range headerOverride {
 		targetHeader.Set(key, value)
 	}
-	err = a.SetupRequestHeader(c, &targetHeader, info)
-	if err != nil {
-		return nil, fmt.Errorf("setup request header failed: %w", err)
-	}
 	targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type"))
 	targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader)
 	if err != nil {

+ 164 - 0
relay/channel/codex/adaptor.go

@@ -0,0 +1,164 @@
+package codex
+
+import (
+	"encoding/json"
+	"errors"
+	"io"
+	"net/http"
+	"strings"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/dto"
+	"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/types"
+
+	"github.com/gin-gonic/gin"
+)
+
+type Adaptor struct {
+}
+
+func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
+	return nil, errors.New("codex channel: endpoint not supported")
+}
+
+func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
+	return nil, errors.New("codex channel: endpoint not supported")
+}
+
+func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
+	return nil, errors.New("codex channel: endpoint not supported")
+}
+
+func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
+	return nil, errors.New("codex channel: endpoint not supported")
+}
+
+func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
+}
+
+func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
+	return nil, errors.New("codex channel: endpoint not supported")
+}
+
+func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
+	return nil, errors.New("codex channel: endpoint not supported")
+}
+
+func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
+	return nil, errors.New("codex channel: endpoint not supported")
+}
+
+func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
+	if info != nil && info.ChannelSetting.SystemPrompt != "" {
+		systemPrompt := info.ChannelSetting.SystemPrompt
+
+		if len(request.Instructions) == 0 {
+			if b, err := common.Marshal(systemPrompt); err == nil {
+				request.Instructions = b
+			} else {
+				return nil, err
+			}
+		} else if info.ChannelSetting.SystemPromptOverride {
+			var existing string
+			if err := common.Unmarshal(request.Instructions, &existing); err == nil {
+				existing = strings.TrimSpace(existing)
+				if existing == "" {
+					if b, err := common.Marshal(systemPrompt); err == nil {
+						request.Instructions = b
+					} else {
+						return nil, err
+					}
+				} else {
+					if b, err := common.Marshal(systemPrompt + "\n" + existing); err == nil {
+						request.Instructions = b
+					} else {
+						return nil, err
+					}
+				}
+			} else {
+				if b, err := common.Marshal(systemPrompt); err == nil {
+					request.Instructions = b
+				} else {
+					return nil, err
+				}
+			}
+		}
+	}
+
+	// codex: store must be false
+	request.Store = json.RawMessage("false")
+	// rm max_output_tokens
+	request.MaxOutputTokens = 0
+	request.Temperature = nil
+	return request, nil
+}
+
+func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
+	return channel.DoApiRequest(a, c, info, requestBody)
+}
+
+func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
+	if info.RelayMode != relayconstant.RelayModeResponses {
+		return nil, types.NewError(errors.New("codex channel: endpoint not supported"), types.ErrorCodeInvalidRequest)
+	}
+
+	if info.IsStream {
+		return openai.OaiResponsesStreamHandler(c, info, resp)
+	}
+	return openai.OaiResponsesHandler(c, info, resp)
+}
+
+func (a *Adaptor) GetModelList() []string {
+	return ModelList
+}
+
+func (a *Adaptor) GetChannelName() string {
+	return ChannelName
+}
+
+func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
+	if info.RelayMode != relayconstant.RelayModeResponses {
+		return "", errors.New("codex channel: only /v1/responses is supported")
+	}
+	return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, "/backend-api/codex/responses", info.ChannelType), nil
+}
+
+func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
+	channel.SetupApiRequestHeader(info, c, req)
+
+	key := strings.TrimSpace(info.ApiKey)
+	if !strings.HasPrefix(key, "{") {
+		return errors.New("codex channel: key must be a JSON object")
+	}
+
+	oauthKey, err := ParseOAuthKey(key)
+	if err != nil {
+		return err
+	}
+
+	accessToken := strings.TrimSpace(oauthKey.AccessToken)
+	accountID := strings.TrimSpace(oauthKey.AccountID)
+
+	if accessToken == "" {
+		return errors.New("codex channel: access_token is required")
+	}
+	if accountID == "" {
+		return errors.New("codex channel: account_id is required")
+	}
+
+	req.Set("Authorization", "Bearer "+accessToken)
+	req.Set("chatgpt-account-id", accountID)
+
+	if req.Get("OpenAI-Beta") == "" {
+		req.Set("OpenAI-Beta", "responses=experimental")
+	}
+	if req.Get("originator") == "" {
+		req.Set("originator", "codex_cli_rs")
+	}
+
+	return nil
+}

+ 9 - 0
relay/channel/codex/constants.go

@@ -0,0 +1,9 @@
+package codex
+
+var ModelList = []string{
+	"gpt-5", "gpt-5-codex", "gpt-5-codex-mini",
+	"gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini",
+	"gpt-5.2", "gpt-5.2-codex",
+}
+
+const ChannelName = "codex"

+ 30 - 0
relay/channel/codex/oauth_key.go

@@ -0,0 +1,30 @@
+package codex
+
+import (
+	"errors"
+
+	"github.com/QuantumNous/new-api/common"
+)
+
+type OAuthKey struct {
+	IDToken      string `json:"id_token,omitempty"`
+	AccessToken  string `json:"access_token,omitempty"`
+	RefreshToken string `json:"refresh_token,omitempty"`
+
+	AccountID   string `json:"account_id,omitempty"`
+	LastRefresh string `json:"last_refresh,omitempty"`
+	Email       string `json:"email,omitempty"`
+	Type        string `json:"type,omitempty"`
+	Expired     string `json:"expired,omitempty"`
+}
+
+func ParseOAuthKey(raw string) (*OAuthKey, error) {
+	if raw == "" {
+		return nil, errors.New("codex channel: empty oauth key")
+	}
+	var key OAuthKey
+	if err := common.Unmarshal([]byte(raw), &key); err != nil {
+		return nil, errors.New("codex channel: invalid oauth key json")
+	}
+	return &key, nil
+}

+ 237 - 70
relay/channel/gemini/relay-gemini.go

@@ -1,6 +1,7 @@
 package gemini
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -8,6 +9,7 @@ import (
 	"net/http"
 	"strconv"
 	"strings"
+	"time"
 	"unicode/utf8"
 
 	"github.com/QuantumNous/new-api/common"
@@ -653,101 +655,84 @@ func getSupportedMimeTypesList() []string {
 	return keys
 }
 
+var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
+	"anyOf":            {},
+	"default":          {},
+	"description":      {},
+	"enum":             {},
+	"example":          {},
+	"format":           {},
+	"items":            {},
+	"maxItems":         {},
+	"maxLength":        {},
+	"maxProperties":    {},
+	"maximum":          {},
+	"minItems":         {},
+	"minLength":        {},
+	"minProperties":    {},
+	"minimum":          {},
+	"nullable":         {},
+	"pattern":          {},
+	"properties":       {},
+	"propertyOrdering": {},
+	"required":         {},
+	"title":            {},
+	"type":             {},
+}
+
+const geminiFunctionSchemaMaxDepth = 64
+
 // cleanFunctionParameters recursively removes unsupported fields from Gemini function parameters.
 func cleanFunctionParameters(params interface{}) interface{} {
+	return cleanFunctionParametersWithDepth(params, 0)
+}
+
+func cleanFunctionParametersWithDepth(params interface{}, depth int) interface{} {
 	if params == nil {
 		return nil
 	}
 
+	if depth >= geminiFunctionSchemaMaxDepth {
+		return cleanFunctionParametersShallow(params)
+	}
+
 	switch v := params.(type) {
 	case map[string]interface{}:
-		// Create a copy to avoid modifying the original
-		cleanedMap := make(map[string]interface{})
+		// Keep only Gemini-supported OpenAPI schema subset fields (per official SDK Schema).
+		cleanedMap := make(map[string]interface{}, len(v))
 		for k, val := range v {
-			cleanedMap[k] = val
-		}
-
-		// Remove unsupported root-level fields
-		delete(cleanedMap, "default")
-		delete(cleanedMap, "exclusiveMaximum")
-		delete(cleanedMap, "exclusiveMinimum")
-		delete(cleanedMap, "$schema")
-		delete(cleanedMap, "additionalProperties")
-
-		// Check and clean 'format' for string types
-		if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" {
-			if formatValue, formatExists := cleanedMap["format"].(string); formatExists {
-				if formatValue != "enum" && formatValue != "date-time" {
-					delete(cleanedMap, "format")
-				}
+			if _, ok := geminiOpenAPISchemaAllowedFields[k]; ok {
+				cleanedMap[k] = val
 			}
 		}
 
+		normalizeGeminiSchemaTypeAndNullable(cleanedMap)
+
 		// Clean properties
 		if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
 			cleanedProps := make(map[string]interface{})
 			for propName, propValue := range props {
-				cleanedProps[propName] = cleanFunctionParameters(propValue)
+				cleanedProps[propName] = cleanFunctionParametersWithDepth(propValue, depth+1)
 			}
 			cleanedMap["properties"] = cleanedProps
 		}
 
 		// Recursively clean items in arrays
 		if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
-			cleanedMap["items"] = cleanFunctionParameters(items)
+			cleanedMap["items"] = cleanFunctionParametersWithDepth(items, depth+1)
 		}
-		// Also handle items if it's an array of schemas
-		if itemsArray, ok := cleanedMap["items"].([]interface{}); ok {
-			cleanedItemsArray := make([]interface{}, len(itemsArray))
-			for i, item := range itemsArray {
-				cleanedItemsArray[i] = cleanFunctionParameters(item)
-			}
-			cleanedMap["items"] = cleanedItemsArray
-		}
-
-		// Recursively clean other schema composition keywords
-		for _, field := range []string{"allOf", "anyOf", "oneOf"} {
-			if nested, ok := cleanedMap[field].([]interface{}); ok {
-				cleanedNested := make([]interface{}, len(nested))
-				for i, item := range nested {
-					cleanedNested[i] = cleanFunctionParameters(item)
-				}
-				cleanedMap[field] = cleanedNested
-			}
-		}
-
-		// Recursively clean patternProperties
-		if patternProps, ok := cleanedMap["patternProperties"].(map[string]interface{}); ok {
-			cleanedPatternProps := make(map[string]interface{})
-			for pattern, schema := range patternProps {
-				cleanedPatternProps[pattern] = cleanFunctionParameters(schema)
-			}
-			cleanedMap["patternProperties"] = cleanedPatternProps
-		}
-
-		// Recursively clean definitions
-		if definitions, ok := cleanedMap["definitions"].(map[string]interface{}); ok {
-			cleanedDefinitions := make(map[string]interface{})
-			for defName, defSchema := range definitions {
-				cleanedDefinitions[defName] = cleanFunctionParameters(defSchema)
-			}
-			cleanedMap["definitions"] = cleanedDefinitions
+		// OpenAPI tuple-style items is not supported by Gemini SDK Schema; keep first to avoid API rejection.
+		if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 {
+			cleanedMap["items"] = cleanFunctionParametersWithDepth(itemsArray[0], depth+1)
 		}
 
-		// Recursively clean $defs (newer JSON Schema draft)
-		if defs, ok := cleanedMap["$defs"].(map[string]interface{}); ok {
-			cleanedDefs := make(map[string]interface{})
-			for defName, defSchema := range defs {
-				cleanedDefs[defName] = cleanFunctionParameters(defSchema)
-			}
-			cleanedMap["$defs"] = cleanedDefs
-		}
-
-		// Clean conditional keywords
-		for _, field := range []string{"if", "then", "else", "not"} {
-			if nested, ok := cleanedMap[field]; ok {
-				cleanedMap[field] = cleanFunctionParameters(nested)
+		// Recursively clean anyOf
+		if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil {
+			cleanedNested := make([]interface{}, len(nested))
+			for i, item := range nested {
+				cleanedNested[i] = cleanFunctionParametersWithDepth(item, depth+1)
 			}
+			cleanedMap["anyOf"] = cleanedNested
 		}
 
 		return cleanedMap
@@ -756,7 +741,7 @@ func cleanFunctionParameters(params interface{}) interface{} {
 		// Handle arrays of schemas
 		cleanedArray := make([]interface{}, len(v))
 		for i, item := range v {
-			cleanedArray[i] = cleanFunctionParameters(item)
+			cleanedArray[i] = cleanFunctionParametersWithDepth(item, depth+1)
 		}
 		return cleanedArray
 
@@ -766,6 +751,91 @@ func cleanFunctionParameters(params interface{}) interface{} {
 	}
 }
 
+func cleanFunctionParametersShallow(params interface{}) interface{} {
+	switch v := params.(type) {
+	case map[string]interface{}:
+		cleanedMap := make(map[string]interface{}, len(v))
+		for k, val := range v {
+			if _, ok := geminiOpenAPISchemaAllowedFields[k]; ok {
+				cleanedMap[k] = val
+			}
+		}
+		normalizeGeminiSchemaTypeAndNullable(cleanedMap)
+		// Stop recursion and avoid retaining huge nested structures.
+		delete(cleanedMap, "properties")
+		delete(cleanedMap, "items")
+		delete(cleanedMap, "anyOf")
+		return cleanedMap
+	case []interface{}:
+		// Prefer an empty list over deep recursion on attacker-controlled inputs.
+		return []interface{}{}
+	default:
+		return params
+	}
+}
+
+func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
+	rawType, ok := schema["type"]
+	if !ok || rawType == nil {
+		return
+	}
+
+	normalize := func(t string) (string, bool) {
+		switch strings.ToLower(strings.TrimSpace(t)) {
+		case "object":
+			return "OBJECT", false
+		case "array":
+			return "ARRAY", false
+		case "string":
+			return "STRING", false
+		case "integer":
+			return "INTEGER", false
+		case "number":
+			return "NUMBER", false
+		case "boolean":
+			return "BOOLEAN", false
+		case "null":
+			return "", true
+		default:
+			return t, false
+		}
+	}
+
+	switch t := rawType.(type) {
+	case string:
+		normalized, isNull := normalize(t)
+		if isNull {
+			schema["nullable"] = true
+			delete(schema, "type")
+			return
+		}
+		schema["type"] = normalized
+	case []interface{}:
+		nullable := false
+		var chosen string
+		for _, item := range t {
+			if s, ok := item.(string); ok {
+				normalized, isNull := normalize(s)
+				if isNull {
+					nullable = true
+					continue
+				}
+				if chosen == "" {
+					chosen = normalized
+				}
+			}
+		}
+		if nullable {
+			schema["nullable"] = true
+		}
+		if chosen != "" {
+			schema["type"] = chosen
+		} else {
+			delete(schema, "type")
+		}
+	}
+}
+
 func removeAdditionalPropertiesWithDepth(schema interface{}, depth int) interface{} {
 	if depth >= 5 {
 		return schema
@@ -1138,6 +1208,8 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
 	id := helper.GetResponseID(c)
 	createAt := common.GetTimestamp()
 	finishReason := constant.FinishReasonStop
+	toolCallIndexByChoice := make(map[int]map[string]int)
+	nextToolCallIndexByChoice := make(map[int]int)
 
 	usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
 		response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
@@ -1145,6 +1217,28 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
 		response.Id = id
 		response.Created = createAt
 		response.Model = info.UpstreamModelName
+		for choiceIdx := range response.Choices {
+			choiceKey := response.Choices[choiceIdx].Index
+			for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
+				tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx]
+				if tool.ID == "" {
+					continue
+				}
+				m := toolCallIndexByChoice[choiceKey]
+				if m == nil {
+					m = make(map[string]int)
+					toolCallIndexByChoice[choiceKey] = m
+				}
+				if idx, ok := m[tool.ID]; ok {
+					tool.SetIndex(idx)
+					continue
+				}
+				idx := nextToolCallIndexByChoice[choiceKey]
+				nextToolCallIndexByChoice[choiceKey] = idx + 1
+				m[tool.ID] = idx
+				tool.SetIndex(idx)
+			}
+		}
 
 		logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount))
 		if info.SendResponseCount == 0 {
@@ -1363,3 +1457,76 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
 
 	return usage, nil
 }
+
+type GeminiModelsResponse struct {
+	Models        []dto.GeminiModel `json:"models"`
+	NextPageToken string            `json:"nextPageToken"`
+}
+
+func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
+	client, err := service.GetHttpClientWithProxy(proxyURL)
+	if err != nil {
+		return nil, fmt.Errorf("创建HTTP客户端失败: %v", err)
+	}
+
+	allModels := make([]string, 0)
+	nextPageToken := ""
+	maxPages := 100 // Safety limit to prevent infinite loops
+
+	for page := 0; page < maxPages; page++ {
+		url := fmt.Sprintf("%s/v1beta/models", baseURL)
+		if nextPageToken != "" {
+			url = fmt.Sprintf("%s?pageToken=%s", url, nextPageToken)
+		}
+
+		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+		request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+		if err != nil {
+			cancel()
+			return nil, fmt.Errorf("创建请求失败: %v", err)
+		}
+
+		request.Header.Set("x-goog-api-key", apiKey)
+
+		response, err := client.Do(request)
+		if err != nil {
+			cancel()
+			return nil, fmt.Errorf("请求失败: %v", err)
+		}
+
+		if response.StatusCode != http.StatusOK {
+			body, _ := io.ReadAll(response.Body)
+			response.Body.Close()
+			cancel()
+			return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
+		}
+
+		body, err := io.ReadAll(response.Body)
+		response.Body.Close()
+		cancel()
+		if err != nil {
+			return nil, fmt.Errorf("读取响应失败: %v", err)
+		}
+
+		var modelsResponse GeminiModelsResponse
+		if err = common.Unmarshal(body, &modelsResponse); err != nil {
+			return nil, fmt.Errorf("解析响应失败: %v", err)
+		}
+
+		for _, model := range modelsResponse.Models {
+			modelNameValue, ok := model.Name.(string)
+			if !ok {
+				continue
+			}
+			modelName := strings.TrimPrefix(modelNameValue, "models/")
+			allModels = append(allModels, modelName)
+		}
+
+		nextPageToken = modelsResponse.NextPageToken
+		if nextPageToken == "" {
+			break
+		}
+	}
+
+	return allModels, nil
+}

+ 3 - 0
relay/channel/minimax/constants.go

@@ -14,6 +14,9 @@ var ModelList = []string{
 	"speech-02-turbo",
 	"speech-01-hd",
 	"speech-01-turbo",
+	"MiniMax-M2.1",
+	"MiniMax-M2.1-lightning",
+	"MiniMax-M2",
 }
 
 var ChannelName = "minimax"

+ 17 - 2
relay/channel/openai/adaptor.go

@@ -187,6 +187,17 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *
 	if info.ChannelType == constant.ChannelTypeOpenAI && "" != info.Organization {
 		header.Set("OpenAI-Organization", info.Organization)
 	}
+	// 检查 Header Override 是否已设置 Authorization,如果已设置则跳过默认设置
+	// 这样可以避免在 Header Override 应用时被覆盖(虽然 Header Override 会在之后应用,但这里作为额外保护)
+	hasAuthOverride := false
+	if len(info.HeadersOverride) > 0 {
+		for k := range info.HeadersOverride {
+			if strings.EqualFold(k, "Authorization") {
+				hasAuthOverride = true
+				break
+			}
+		}
+	}
 	if info.RelayMode == relayconstant.RelayModeRealtime {
 		swp := c.Request.Header.Get("Sec-WebSocket-Protocol")
 		if swp != "" {
@@ -201,10 +212,14 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *
 			//req.Header.Set("Sec-Websocket-Version", c.Request.Header.Get("Sec-Websocket-Version"))
 		} else {
 			header.Set("openai-beta", "realtime=v1")
-			header.Set("Authorization", "Bearer "+info.ApiKey)
+			if !hasAuthOverride {
+				header.Set("Authorization", "Bearer "+info.ApiKey)
+			}
 		}
 	} else {
-		header.Set("Authorization", "Bearer "+info.ApiKey)
+		if !hasAuthOverride {
+			header.Set("Authorization", "Bearer "+info.ApiKey)
+		}
 	}
 	if info.ChannelType == constant.ChannelTypeOpenRouter {
 		header.Set("HTTP-Referer", "https://www.newapi.ai")

+ 369 - 0
relay/channel/openai/chat_via_responses.go

@@ -0,0 +1,369 @@
+package openai
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/logger"
+	relaycommon "github.com/QuantumNous/new-api/relay/common"
+	"github.com/QuantumNous/new-api/relay/helper"
+	"github.com/QuantumNous/new-api/service"
+	"github.com/QuantumNous/new-api/types"
+
+	"github.com/gin-gonic/gin"
+)
+
+func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+	if resp == nil || resp.Body == nil {
+		return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
+	}
+
+	defer service.CloseResponseBodyGracefully(resp)
+
+	var responsesResp dto.OpenAIResponsesResponse
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
+	}
+
+	if err := common.Unmarshal(body, &responsesResp); err != nil {
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+	}
+
+	if oaiError := responsesResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
+		return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
+	}
+
+	chatId := helper.GetResponseID(c)
+	chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId)
+	if err != nil {
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+	}
+
+	if usage == nil || usage.TotalTokens == 0 {
+		text := service.ExtractOutputTextFromResponses(&responsesResp)
+		usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
+		chatResp.Usage = *usage
+	}
+
+	chatBody, err := common.Marshal(chatResp)
+	if err != nil {
+		return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
+	}
+
+	service.IOCopyBytesGracefully(c, resp, chatBody)
+	return usage, nil
+}
+
+func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+	if resp == nil || resp.Body == nil {
+		return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
+	}
+
+	defer service.CloseResponseBodyGracefully(resp)
+
+	responseId := helper.GetResponseID(c)
+	createAt := time.Now().Unix()
+	model := info.UpstreamModelName
+
+	var (
+		usage       = &dto.Usage{}
+		outputText  strings.Builder
+		usageText   strings.Builder
+		sentStart   bool
+		sentStop    bool
+		sawToolCall bool
+		streamErr   *types.NewAPIError
+	)
+
+	toolCallIndexByID := make(map[string]int)
+	toolCallNameByID := make(map[string]string)
+	toolCallArgsByID := make(map[string]string)
+	toolCallNameSent := make(map[string]bool)
+	toolCallCanonicalIDByItemID := make(map[string]string)
+
+	sendStartIfNeeded := func() bool {
+		if sentStart {
+			return true
+		}
+		if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
+			streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+			return false
+		}
+		sentStart = true
+		return true
+	}
+
+	sendToolCallDelta := func(callID string, name string, argsDelta string) bool {
+		if callID == "" {
+			return true
+		}
+		if outputText.Len() > 0 {
+			// Prefer streaming assistant text over tool calls to match non-stream behavior.
+			return true
+		}
+		if !sendStartIfNeeded() {
+			return false
+		}
+
+		idx, ok := toolCallIndexByID[callID]
+		if !ok {
+			idx = len(toolCallIndexByID)
+			toolCallIndexByID[callID] = idx
+		}
+		if name != "" {
+			toolCallNameByID[callID] = name
+		}
+		if toolCallNameByID[callID] != "" {
+			name = toolCallNameByID[callID]
+		}
+
+		tool := dto.ToolCallResponse{
+			ID:   callID,
+			Type: "function",
+			Function: dto.FunctionResponse{
+				Arguments: argsDelta,
+			},
+		}
+		tool.SetIndex(idx)
+		if name != "" && !toolCallNameSent[callID] {
+			tool.Function.Name = name
+			toolCallNameSent[callID] = true
+		}
+
+		chunk := &dto.ChatCompletionsStreamResponse{
+			Id:      responseId,
+			Object:  "chat.completion.chunk",
+			Created: createAt,
+			Model:   model,
+			Choices: []dto.ChatCompletionsStreamResponseChoice{
+				{
+					Index: 0,
+					Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
+						ToolCalls: []dto.ToolCallResponse{tool},
+					},
+				},
+			},
+		}
+		if err := helper.ObjectData(c, chunk); err != nil {
+			streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+			return false
+		}
+		sawToolCall = true
+
+		// Include tool call data in the local builder for fallback token estimation.
+		if tool.Function.Name != "" {
+			usageText.WriteString(tool.Function.Name)
+		}
+		if argsDelta != "" {
+			usageText.WriteString(argsDelta)
+		}
+		return true
+	}
+
+	helper.StreamScannerHandler(c, resp, info, func(data string) bool {
+		if streamErr != nil {
+			return false
+		}
+
+		var streamResp dto.ResponsesStreamResponse
+		if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
+			logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error())
+			return true
+		}
+
+		switch streamResp.Type {
+		case "response.created":
+			if streamResp.Response != nil {
+				if streamResp.Response.Model != "" {
+					model = streamResp.Response.Model
+				}
+				if streamResp.Response.CreatedAt != 0 {
+					createAt = int64(streamResp.Response.CreatedAt)
+				}
+			}
+
+		case "response.output_text.delta":
+			if !sendStartIfNeeded() {
+				return false
+			}
+
+			if streamResp.Delta != "" {
+				outputText.WriteString(streamResp.Delta)
+				usageText.WriteString(streamResp.Delta)
+				delta := streamResp.Delta
+				chunk := &dto.ChatCompletionsStreamResponse{
+					Id:      responseId,
+					Object:  "chat.completion.chunk",
+					Created: createAt,
+					Model:   model,
+					Choices: []dto.ChatCompletionsStreamResponseChoice{
+						{
+							Index: 0,
+							Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
+								Content: &delta,
+							},
+						},
+					},
+				}
+				if err := helper.ObjectData(c, chunk); err != nil {
+					streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+					return false
+				}
+			}
+
+		case "response.output_item.added", "response.output_item.done":
+			if streamResp.Item == nil {
+				break
+			}
+			if streamResp.Item.Type != "function_call" {
+				break
+			}
+
+			itemID := strings.TrimSpace(streamResp.Item.ID)
+			callID := strings.TrimSpace(streamResp.Item.CallId)
+			if callID == "" {
+				callID = itemID
+			}
+			if itemID != "" && callID != "" {
+				toolCallCanonicalIDByItemID[itemID] = callID
+			}
+			name := strings.TrimSpace(streamResp.Item.Name)
+			if name != "" {
+				toolCallNameByID[callID] = name
+			}
+
+			newArgs := streamResp.Item.Arguments
+			prevArgs := toolCallArgsByID[callID]
+			argsDelta := ""
+			if newArgs != "" {
+				if strings.HasPrefix(newArgs, prevArgs) {
+					argsDelta = newArgs[len(prevArgs):]
+				} else {
+					argsDelta = newArgs
+				}
+				toolCallArgsByID[callID] = newArgs
+			}
+
+			if !sendToolCallDelta(callID, name, argsDelta) {
+				return false
+			}
+
+		case "response.function_call_arguments.delta":
+			itemID := strings.TrimSpace(streamResp.ItemID)
+			callID := toolCallCanonicalIDByItemID[itemID]
+			if callID == "" {
+				callID = itemID
+			}
+			if callID == "" {
+				break
+			}
+			toolCallArgsByID[callID] += streamResp.Delta
+			if !sendToolCallDelta(callID, "", streamResp.Delta) {
+				return false
+			}
+
+		case "response.function_call_arguments.done":
+
+		case "response.completed":
+			if streamResp.Response != nil {
+				if streamResp.Response.Model != "" {
+					model = streamResp.Response.Model
+				}
+				if streamResp.Response.CreatedAt != 0 {
+					createAt = int64(streamResp.Response.CreatedAt)
+				}
+				if streamResp.Response.Usage != nil {
+					if streamResp.Response.Usage.InputTokens != 0 {
+						usage.PromptTokens = streamResp.Response.Usage.InputTokens
+						usage.InputTokens = streamResp.Response.Usage.InputTokens
+					}
+					if streamResp.Response.Usage.OutputTokens != 0 {
+						usage.CompletionTokens = streamResp.Response.Usage.OutputTokens
+						usage.OutputTokens = streamResp.Response.Usage.OutputTokens
+					}
+					if streamResp.Response.Usage.TotalTokens != 0 {
+						usage.TotalTokens = streamResp.Response.Usage.TotalTokens
+					} else {
+						usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+					}
+					if streamResp.Response.Usage.InputTokensDetails != nil {
+						usage.PromptTokensDetails.CachedTokens = streamResp.Response.Usage.InputTokensDetails.CachedTokens
+						usage.PromptTokensDetails.ImageTokens = streamResp.Response.Usage.InputTokensDetails.ImageTokens
+						usage.PromptTokensDetails.AudioTokens = streamResp.Response.Usage.InputTokensDetails.AudioTokens
+					}
+					if streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens != 0 {
+						usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens
+					}
+				}
+			}
+
+			if !sendStartIfNeeded() {
+				return false
+			}
+			if !sentStop {
+				finishReason := "stop"
+				if sawToolCall && outputText.Len() == 0 {
+					finishReason = "tool_calls"
+				}
+				stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
+				if err := helper.ObjectData(c, stop); err != nil {
+					streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+					return false
+				}
+				sentStop = true
+			}
+
+		case "response.error", "response.failed":
+			if streamResp.Response != nil {
+				if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
+					streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
+					return false
+				}
+			}
+			streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
+			return false
+
+		default:
+		}
+
+		return true
+	})
+
+	if streamErr != nil {
+		return nil, streamErr
+	}
+
+	if usage.TotalTokens == 0 {
+		usage = service.ResponseText2Usage(c, usageText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
+	}
+
+	if !sentStart {
+		if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
+			return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+		}
+	}
+	if !sentStop {
+		finishReason := "stop"
+		if sawToolCall && outputText.Len() == 0 {
+			finishReason = "tool_calls"
+		}
+		stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
+		if err := helper.ObjectData(c, stop); err != nil {
+			return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+		}
+	}
+	if info.ShouldIncludeUsage && usage != nil {
+		if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, createAt, model, *usage)); err != nil {
+			return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+		}
+	}
+
+	helper.Done(c)
+	return usage, nil
+}

+ 75 - 17
relay/channel/task/doubao/adaptor.go

@@ -6,6 +6,9 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
 
 	"github.com/QuantumNous/new-api/constant"
 	"github.com/QuantumNous/new-api/dto"
@@ -23,18 +26,36 @@ import (
 // ============================
 
 type ContentItem struct {
-	Type     string    `json:"type"`                // "text" or "image_url"
-	Text     string    `json:"text,omitempty"`      // for text type
-	ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
+	Type     string          `json:"type"`                // "text", "image_url" or "video"
+	Text     string          `json:"text,omitempty"`      // for text type
+	ImageURL *ImageURL       `json:"image_url,omitempty"` // for image_url type
+	Video    *VideoReference `json:"video,omitempty"`     // for video (sample) type
 }
 
 type ImageURL struct {
 	URL string `json:"url"`
 }
 
+type VideoReference struct {
+	URL string `json:"url"` // Draft video URL
+}
+
 type requestPayload struct {
-	Model   string        `json:"model"`
-	Content []ContentItem `json:"content"`
+	Model                 string         `json:"model"`
+	Content               []ContentItem  `json:"content"`
+	CallbackURL           string         `json:"callback_url,omitempty"`
+	ReturnLastFrame       *dto.BoolValue `json:"return_last_frame,omitempty"`
+	ServiceTier           string         `json:"service_tier,omitempty"`
+	ExecutionExpiresAfter dto.IntValue   `json:"execution_expires_after,omitempty"`
+	GenerateAudio         *dto.BoolValue `json:"generate_audio,omitempty"`
+	Draft                 *dto.BoolValue `json:"draft,omitempty"`
+	Resolution            string         `json:"resolution,omitempty"`
+	Ratio                 string         `json:"ratio,omitempty"`
+	Duration              dto.IntValue   `json:"duration,omitempty"`
+	Frames                dto.IntValue   `json:"frames,omitempty"`
+	Seed                  dto.IntValue   `json:"seed,omitempty"`
+	CameraFixed           *dto.BoolValue `json:"camera_fixed,omitempty"`
+	Watermark             *dto.BoolValue `json:"watermark,omitempty"`
 }
 
 type responsePayload struct {
@@ -53,6 +74,7 @@ type responseTask struct {
 	Duration        int    `json:"duration"`
 	Ratio           string `json:"ratio"`
 	FramesPerSecond int    `json:"framespersecond"`
+	ServiceTier     string `json:"service_tier"`
 	Usage           struct {
 		CompletionTokens int `json:"completion_tokens"`
 		TotalTokens      int `json:"total_tokens"`
@@ -98,16 +120,16 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info
 
 // BuildRequestBody converts request into Doubao specific format.
 func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
-	v, exists := c.Get("task_request")
-	if !exists {
-		return nil, fmt.Errorf("request not found in context")
+	req, err := relaycommon.GetTaskRequest(c)
+	if err != nil {
+		return nil, err
 	}
-	req := v.(relaycommon.TaskSubmitReq)
 
 	body, err := a.convertToRequestPayload(&req)
 	if err != nil {
 		return nil, errors.Wrap(err, "convert request payload failed")
 	}
+	info.UpstreamModelName = body.Model
 	data, err := json.Marshal(body)
 	if err != nil {
 		return nil, err
@@ -141,7 +163,13 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela
 		return
 	}
 
-	c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
+	ov := dto.NewOpenAIVideo()
+	ov.ID = dResp.ID
+	ov.TaskID = dResp.ID
+	ov.CreatedAt = time.Now().Unix()
+	ov.Model = info.OriginModelName
+
+	c.JSON(http.StatusOK, ov)
 	return dResp.ID, responseBody, nil
 }
 
@@ -204,12 +232,15 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
 		}
 	}
 
-	// TODO: Add support for additional parameters from metadata
-	// such as ratio, duration, seed, etc.
-	// metadata := req.Metadata
-	// if metadata != nil {
-	//     // Parse and apply metadata parameters
-	// }
+	metadata := req.Metadata
+	medaBytes, err := json.Marshal(metadata)
+	if err != nil {
+		return nil, errors.Wrap(err, "metadata marshal metadata failed")
+	}
+	err = json.Unmarshal(medaBytes, &r)
+	if err != nil {
+		return nil, errors.Wrap(err, "unmarshal metadata failed")
+	}
 
 	return &r, nil
 }
@@ -229,7 +260,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
 	case "pending", "queued":
 		taskResult.Status = model.TaskStatusQueued
 		taskResult.Progress = "10%"
-	case "processing":
+	case "processing", "running":
 		taskResult.Status = model.TaskStatusInProgress
 		taskResult.Progress = "50%"
 	case "succeeded":
@@ -251,3 +282,30 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
 
 	return &taskResult, nil
 }
+
+func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
+	var dResp responseTask
+	if err := json.Unmarshal(originTask.Data, &dResp); err != nil {
+		return nil, errors.Wrap(err, "unmarshal doubao task data failed")
+	}
+
+	openAIVideo := dto.NewOpenAIVideo()
+	openAIVideo.ID = originTask.TaskID
+	openAIVideo.TaskID = originTask.TaskID
+	openAIVideo.Status = originTask.Status.ToVideoStatus()
+	openAIVideo.SetProgressStr(originTask.Progress)
+	openAIVideo.SetMetadata("url", dResp.Content.VideoURL)
+	openAIVideo.CreatedAt = originTask.CreatedAt
+	openAIVideo.CompletedAt = originTask.UpdatedAt
+	openAIVideo.Model = originTask.Properties.OriginModelName
+
+	if dResp.Status == "failed" {
+		openAIVideo.Error = &dto.OpenAIVideoError{
+			Message: "task failed",
+			Code:    "failed",
+		}
+	}
+
+	jsonData, _ := common.Marshal(openAIVideo)
+	return jsonData, nil
+}

+ 1 - 0
relay/channel/task/doubao/constants.go

@@ -4,6 +4,7 @@ var ModelList = []string{
 	"doubao-seedance-1-0-pro-250528",
 	"doubao-seedance-1-0-lite-t2v",
 	"doubao-seedance-1-0-lite-i2v",
+	"doubao-seedance-1-5-pro-251215",
 }
 
 var ChannelName = "doubao-video"

+ 4 - 2
relay/channel/task/jimeng/adaptor.go

@@ -17,6 +17,7 @@ import (
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/model"
+	"github.com/samber/lo"
 
 	"github.com/gin-gonic/gin"
 	"github.com/pkg/errors"
@@ -409,14 +410,15 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
 
 	// 即梦视频3.0 ReqKey转换
 	// https://www.volcengine.com/docs/85621/1792707
+	imageLen := lo.Max([]int{len(req.Images), len(r.BinaryDataBase64), len(r.ImageUrls)})
 	if strings.Contains(r.ReqKey, "jimeng_v30") {
 		if r.ReqKey == "jimeng_v30_pro" {
 			// 3.0 pro只有固定的jimeng_ti2v_v30_pro
 			r.ReqKey = "jimeng_ti2v_v30_pro"
-		} else if len(req.Images) > 1 {
+		} else if imageLen > 1 {
 			// 多张图片:首尾帧生成
 			r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1), "p")
-		} else if len(req.Images) == 1 {
+		} else if imageLen == 1 {
 			// 单张图片:图生视频
 			r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1), "p")
 		} else {

+ 162 - 0
relay/chat_completions_via_responses.go

@@ -0,0 +1,162 @@
+package relay
+
+import (
+	"bytes"
+	"net/http"
+	"strings"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/relay/channel"
+	openaichannel "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"
+)
+
+func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) {
+	if info == nil || request == nil {
+		return
+	}
+	if info.ChannelSetting.SystemPrompt == "" {
+		return
+	}
+
+	systemRole := request.GetSystemRoleName()
+
+	containSystemPrompt := false
+	for _, message := range request.Messages {
+		if message.Role == systemRole {
+			containSystemPrompt = true
+			break
+		}
+	}
+	if !containSystemPrompt {
+		systemMessage := dto.Message{
+			Role:    systemRole,
+			Content: info.ChannelSetting.SystemPrompt,
+		}
+		request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
+		return
+	}
+
+	if !info.ChannelSetting.SystemPromptOverride {
+		return
+	}
+
+	common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
+	for i, message := range request.Messages {
+		if message.Role != systemRole {
+			continue
+		}
+		if message.IsStringContent() {
+			request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
+			return
+		}
+		contents := message.ParseContent()
+		contents = append([]dto.MediaContent{
+			{
+				Type: dto.ContentTypeText,
+				Text: info.ChannelSetting.SystemPrompt,
+			},
+		}, contents...)
+		request.Messages[i].Content = contents
+		return
+	}
+}
+
+func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) {
+	overrideCtx := relaycommon.BuildParamOverrideContext(info)
+	chatJSON, err := common.Marshal(request)
+	if err != nil {
+		return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+	}
+
+	chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings)
+	if err != nil {
+		return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+	}
+
+	if len(info.ParamOverride) > 0 {
+		chatJSON, err = relaycommon.ApplyParamOverride(chatJSON, info.ParamOverride, overrideCtx)
+		if err != nil {
+			return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
+		}
+	}
+
+	var overriddenChatReq dto.GeneralOpenAIRequest
+	if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil {
+		return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
+	}
+
+	responsesReq, err := service.ChatCompletionsRequestToResponsesRequest(&overriddenChatReq)
+	if err != nil {
+		return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
+	}
+	info.AppendRequestConversion(types.RelayFormatOpenAIResponses)
+
+	savedRelayMode := info.RelayMode
+	savedRequestURLPath := info.RequestURLPath
+	defer func() {
+		info.RelayMode = savedRelayMode
+		info.RequestURLPath = savedRequestURLPath
+	}()
+
+	info.RelayMode = relayconstant.RelayModeResponses
+	info.RequestURLPath = "/v1/responses"
+
+	convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *responsesReq)
+	if err != nil {
+		return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+	}
+	relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
+
+	jsonData, err := common.Marshal(convertedRequest)
+	if err != nil {
+		return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+	}
+
+	jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
+	if err != nil {
+		return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+	}
+
+	var httpResp *http.Response
+	resp, err := adaptor.DoRequest(c, info, bytes.NewBuffer(jsonData))
+	if err != nil {
+		return nil, types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
+	}
+	if resp == nil {
+		return nil, types.NewOpenAIError(nil, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+	}
+
+	statusCodeMappingStr := c.GetString("status_code_mapping")
+
+	httpResp = resp.(*http.Response)
+	info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
+	if httpResp.StatusCode != http.StatusOK {
+		newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
+		service.ResetStatusCode(newApiErr, statusCodeMappingStr)
+		return nil, newApiErr
+	}
+
+	if info.IsStream {
+		usage, newApiErr := openaichannel.OaiResponsesToChatStreamHandler(c, info, httpResp)
+		if newApiErr != nil {
+			service.ResetStatusCode(newApiErr, statusCodeMappingStr)
+			return nil, newApiErr
+		}
+		return usage, nil
+	}
+
+	usage, newApiErr := openaichannel.OaiResponsesToChatHandler(c, info, httpResp)
+	if newApiErr != nil {
+		service.ResetStatusCode(newApiErr, statusCodeMappingStr)
+		return nil, newApiErr
+	}
+	return usage, nil
+}

+ 1 - 0
relay/claude_handler.go

@@ -110,6 +110,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
+		relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
 		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())

+ 14 - 8
relay/common/override.go

@@ -570,18 +570,19 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
 
 // BuildParamOverrideContext 提供 ApplyParamOverride 可用的上下文信息。
 // 目前内置以下字段:
-//   - model:优先使用上游模型名(UpstreamModelName),若不存在则回落到原始模型名(OriginModelName)。
-//   - upstream_model:始终为通道映射后的上游模型名。
+//   - upstream_model/model:始终为通道映射后的上游模型名。
 //   - original_model:请求最初指定的模型名。
+//   - request_path:请求路径
+//   - is_channel_test:是否为渠道测试请求(同 is_test)。
 func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} {
-	if info == nil || info.ChannelMeta == nil {
+	if info == nil {
 		return nil
 	}
 
 	ctx := make(map[string]interface{})
-	if info.UpstreamModelName != "" {
-		ctx["model"] = info.UpstreamModelName
-		ctx["upstream_model"] = info.UpstreamModelName
+	if info.ChannelMeta != nil && info.ChannelMeta.UpstreamModelName != "" {
+		ctx["model"] = info.ChannelMeta.UpstreamModelName
+		ctx["upstream_model"] = info.ChannelMeta.UpstreamModelName
 	}
 	if info.OriginModelName != "" {
 		ctx["original_model"] = info.OriginModelName
@@ -590,8 +591,13 @@ func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} {
 		}
 	}
 
-	if len(ctx) == 0 {
-		return nil
+	if info.RequestURLPath != "" {
+		requestPath := info.RequestURLPath
+		if requestPath != "" {
+			ctx["request_path"] = requestPath
+		}
 	}
+
+	ctx["is_channel_test"] = info.IsChannelTest
 	return ctx
 }

+ 65 - 14
relay/common/relay_info.go

@@ -115,11 +115,16 @@ type RelayInfo struct {
 	SendResponseCount      int
 	FinalPreConsumedQuota  int  // 最终预消耗的配额
 	IsClaudeBetaQuery      bool // /v1/messages?beta=true
+	IsChannelTest          bool // channel test request
 
 	PriceData types.PriceData
 
 	Request dto.Request
 
+	// RequestConversionChain records request format conversions in order, e.g.
+	// ["openai", "openai_responses"] or ["openai", "claude"].
+	RequestConversionChain []types.RelayFormat
+
 	ThinkingContentInfo
 	TokenCountMeta
 	*ClaudeConvertInfo
@@ -273,6 +278,7 @@ var streamSupportedChannels = map[int]bool{
 	constant.ChannelTypeZhipu_v4:   true,
 	constant.ChannelTypeAli:        true,
 	constant.ChannelTypeSubmodel:   true,
+	constant.ChannelTypeCodex:      true,
 }
 
 func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
@@ -446,38 +452,83 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
 }
 
 func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) {
+	var info *RelayInfo
+	var err error
 	switch relayFormat {
 	case types.RelayFormatOpenAI:
-		return GenRelayInfoOpenAI(c, request), nil
+		info = GenRelayInfoOpenAI(c, request)
 	case types.RelayFormatOpenAIAudio:
-		return GenRelayInfoOpenAIAudio(c, request), nil
+		info = GenRelayInfoOpenAIAudio(c, request)
 	case types.RelayFormatOpenAIImage:
-		return GenRelayInfoImage(c, request), nil
+		info = GenRelayInfoImage(c, request)
 	case types.RelayFormatOpenAIRealtime:
-		return GenRelayInfoWs(c, ws), nil
+		info = GenRelayInfoWs(c, ws)
 	case types.RelayFormatClaude:
-		return GenRelayInfoClaude(c, request), nil
+		info = GenRelayInfoClaude(c, request)
 	case types.RelayFormatRerank:
 		if request, ok := request.(*dto.RerankRequest); ok {
-			return GenRelayInfoRerank(c, request), nil
+			info = GenRelayInfoRerank(c, request)
+			break
 		}
-		return nil, errors.New("request is not a RerankRequest")
+		err = errors.New("request is not a RerankRequest")
 	case types.RelayFormatGemini:
-		return GenRelayInfoGemini(c, request), nil
+		info = GenRelayInfoGemini(c, request)
 	case types.RelayFormatEmbedding:
-		return GenRelayInfoEmbedding(c, request), nil
+		info = GenRelayInfoEmbedding(c, request)
 	case types.RelayFormatOpenAIResponses:
 		if request, ok := request.(*dto.OpenAIResponsesRequest); ok {
-			return GenRelayInfoResponses(c, request), nil
+			info = GenRelayInfoResponses(c, request)
+			break
 		}
-		return nil, errors.New("request is not a OpenAIResponsesRequest")
+		err = errors.New("request is not a OpenAIResponsesRequest")
 	case types.RelayFormatTask:
-		return genBaseRelayInfo(c, nil), nil
+		info = genBaseRelayInfo(c, nil)
 	case types.RelayFormatMjProxy:
-		return genBaseRelayInfo(c, nil), nil
+		info = genBaseRelayInfo(c, nil)
 	default:
-		return nil, errors.New("invalid relay format")
+		err = errors.New("invalid relay format")
+	}
+
+	if err != nil {
+		return nil, err
+	}
+	if info == nil {
+		return nil, errors.New("failed to build relay info")
+	}
+
+	info.InitRequestConversionChain()
+	return info, nil
+}
+
+func (info *RelayInfo) InitRequestConversionChain() {
+	if info == nil {
+		return
+	}
+	if len(info.RequestConversionChain) > 0 {
+		return
+	}
+	if info.RelayFormat == "" {
+		return
+	}
+	info.RequestConversionChain = []types.RelayFormat{info.RelayFormat}
+}
+
+func (info *RelayInfo) AppendRequestConversion(format types.RelayFormat) {
+	if info == nil {
+		return
+	}
+	if format == "" {
+		return
+	}
+	if len(info.RequestConversionChain) == 0 {
+		info.RequestConversionChain = []types.RelayFormat{format}
+		return
+	}
+	last := info.RequestConversionChain[len(info.RequestConversionChain)-1]
+	if last == format {
+		return
 	}
+	info.RequestConversionChain = append(info.RequestConversionChain, format)
 }
 
 //func (info *RelayInfo) SetPromptTokens(promptTokens int) {

+ 40 - 0
relay/common/request_conversion.go

@@ -0,0 +1,40 @@
+package common
+
+import (
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/types"
+)
+
+func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) {
+	switch req.(type) {
+	case *dto.GeneralOpenAIRequest, dto.GeneralOpenAIRequest:
+		return types.RelayFormatOpenAI, true
+	case *dto.OpenAIResponsesRequest, dto.OpenAIResponsesRequest:
+		return types.RelayFormatOpenAIResponses, true
+	case *dto.ClaudeRequest, dto.ClaudeRequest:
+		return types.RelayFormatClaude, true
+	case *dto.GeminiChatRequest, dto.GeminiChatRequest:
+		return types.RelayFormatGemini, true
+	case *dto.EmbeddingRequest, dto.EmbeddingRequest:
+		return types.RelayFormatEmbedding, true
+	case *dto.RerankRequest, dto.RerankRequest:
+		return types.RelayFormatRerank, true
+	case *dto.ImageRequest, dto.ImageRequest:
+		return types.RelayFormatOpenAIImage, true
+	case *dto.AudioRequest, dto.AudioRequest:
+		return types.RelayFormatOpenAIAudio, true
+	default:
+		return "", false
+	}
+}
+
+func AppendRequestConversionFromRequest(info *RelayInfo, req any) {
+	if info == nil {
+		return
+	}
+	format, ok := GuessRelayFormatFromRequest(req)
+	if !ok {
+		return
+	}
+	info.AppendRequestConversion(format)
+}

+ 44 - 3
relay/compatible_handler.go

@@ -14,6 +14,7 @@ import (
 	"github.com/QuantumNous/new-api/logger"
 	"github.com/QuantumNous/new-api/model"
 	relaycommon "github.com/QuantumNous/new-api/relay/common"
+	relayconstant "github.com/QuantumNous/new-api/relay/constant"
 	"github.com/QuantumNous/new-api/relay/helper"
 	"github.com/QuantumNous/new-api/service"
 	"github.com/QuantumNous/new-api/setting/model_setting"
@@ -73,9 +74,32 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
 		return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(info)
+
+	passThroughGlobal := model_setting.GetGlobalSettings().PassThroughRequestEnabled
+	if info.RelayMode == relayconstant.RelayModeChatCompletions &&
+		!passThroughGlobal &&
+		!info.ChannelSetting.PassThroughBodyEnabled &&
+		shouldChatCompletionsViaResponses(info) {
+		applySystemPromptIfNeeded(c, info, request)
+		usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
+		if newApiErr != nil {
+			return newApiErr
+		}
+
+		var containAudioTokens = usage.CompletionTokenDetails.AudioTokens > 0 || usage.PromptTokensDetails.AudioTokens > 0
+		var containsAudioRatios = ratio_setting.ContainsAudioRatio(info.OriginModelName) || ratio_setting.ContainsAudioCompletionRatio(info.OriginModelName)
+
+		if containAudioTokens && containsAudioRatios {
+			service.PostAudioConsumeQuota(c, info, usage, "")
+		} else {
+			postConsumeQuota(c, info, usage)
+		}
+		return nil
+	}
+
 	var requestBody io.Reader
 
-	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
+	if passThroughGlobal || info.ChannelSetting.PassThroughBodyEnabled {
 		body, err := common.GetRequestBody(c)
 		if err != nil {
 			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
@@ -89,6 +113,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
+		relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
 
 		if info.ChannelSetting.SystemPrompt != "" {
 			// 如果有系统提示,则将其添加到请求中
@@ -193,6 +218,16 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
 	return nil
 }
 
+func shouldChatCompletionsViaResponses(info *relaycommon.RelayInfo) bool {
+	if info == nil {
+		return false
+	}
+	if info.RelayMode != relayconstant.RelayModeChatCompletions {
+		return false
+	}
+	return service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName)
+}
+
 func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
 	if usage == nil {
 		usage = &dto.Usage{
@@ -301,6 +336,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
 
 	var audioInputQuota decimal.Decimal
 	var audioInputPrice float64
+	isClaudeUsageSemantic := relayInfo.ChannelType == constant.ChannelTypeAnthropic
 	if !relayInfo.PriceData.UsePrice {
 		baseTokens := dPromptTokens
 		// 减去 cached tokens
@@ -308,14 +344,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
 		// OpenAI/OpenRouter 等 API 的 prompt_tokens 包含缓存 tokens,需要减去
 		var cachedTokensWithRatio decimal.Decimal
 		if !dCacheTokens.IsZero() {
-			if relayInfo.ChannelType != constant.ChannelTypeAnthropic {
+			if !isClaudeUsageSemantic {
 				baseTokens = baseTokens.Sub(dCacheTokens)
 			}
 			cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
 		}
 		var dCachedCreationTokensWithRatio decimal.Decimal
 		if !dCachedCreationTokens.IsZero() {
-			if relayInfo.ChannelType != constant.ChannelTypeAnthropic {
+			if !isClaudeUsageSemantic {
 				baseTokens = baseTokens.Sub(dCachedCreationTokens)
 			}
 			dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio)
@@ -425,6 +461,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
 	}
 	logContent := strings.Join(extraContent, ", ")
 	other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
+	// For chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently.
+	if isClaudeUsageSemantic {
+		other["claude"] = true
+		other["usage_semantic"] = "anthropic"
+	}
 	if imageTokens != 0 {
 		other["image"] = true
 		other["image_ratio"] = imageRatio

+ 1 - 0
relay/embedding_handler.go

@@ -45,6 +45,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
 	if err != nil {
 		return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 	}
+	relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
 	jsonData, err := json.Marshal(convertedRequest)
 	if err != nil {
 		return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())

+ 1 - 0
relay/gemini_handler.go

@@ -149,6 +149,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
+		relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
 		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())

+ 1 - 0
relay/image_handler.go

@@ -57,6 +57,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
 		}
+		relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
 
 		switch convertedRequest.(type) {
 		case *bytes.Buffer:

+ 4 - 1
relay/relay_adaptor.go

@@ -11,6 +11,7 @@ import (
 	"github.com/QuantumNous/new-api/relay/channel/baidu_v2"
 	"github.com/QuantumNous/new-api/relay/channel/claude"
 	"github.com/QuantumNous/new-api/relay/channel/cloudflare"
+	"github.com/QuantumNous/new-api/relay/channel/codex"
 	"github.com/QuantumNous/new-api/relay/channel/cohere"
 	"github.com/QuantumNous/new-api/relay/channel/coze"
 	"github.com/QuantumNous/new-api/relay/channel/deepseek"
@@ -117,6 +118,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
 		return &minimax.Adaptor{}
 	case constant.APITypeReplicate:
 		return &replicate.Adaptor{}
+	case constant.APITypeCodex:
+		return &codex.Adaptor{}
 	}
 	return nil
 }
@@ -148,7 +151,7 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
 			return &taskvertex.TaskAdaptor{}
 		case constant.ChannelTypeVidu:
 			return &taskVidu.TaskAdaptor{}
-		case constant.ChannelTypeDoubaoVideo:
+		case constant.ChannelTypeDoubaoVideo, constant.ChannelTypeVolcEngine:
 			return &taskdoubao.TaskAdaptor{}
 		case constant.ChannelTypeSora, constant.ChannelTypeOpenAI:
 			return &tasksora.TaskAdaptor{}

+ 1 - 0
relay/rerank_handler.go

@@ -53,6 +53,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
+		relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
 		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())

+ 1 - 0
relay/responses_handler.go

@@ -53,6 +53,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
+		relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
 		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())

+ 6 - 0
router/api-router.go

@@ -156,6 +156,12 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.POST("/fix", controller.FixChannelsAbilities)
 			channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
 			channelRoute.POST("/fetch_models", controller.FetchModels)
+			channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth)
+			channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth)
+			channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel)
+			channelRoute.POST("/:id/codex/oauth/complete", controller.CompleteCodexOAuthForChannel)
+			channelRoute.POST("/:id/codex/refresh", controller.RefreshCodexChannelCredential)
+			channelRoute.GET("/:id/codex/usage", controller.GetCodexChannelUsage)
 			channelRoute.POST("/ollama/pull", controller.OllamaPullModel)
 			channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream)
 			channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel)

+ 4 - 1
service/channel.go

@@ -57,9 +57,12 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
 	if types.IsSkipRetryError(err) {
 		return false
 	}
-	if err.StatusCode == http.StatusUnauthorized {
+	if operation_setting.ShouldDisableByStatusCode(err.StatusCode) {
 		return true
 	}
+	//if err.StatusCode == http.StatusUnauthorized {
+	//	return true
+	//}
 	if err.StatusCode == http.StatusForbidden {
 		switch channelType {
 		case constant.ChannelTypeGemini:

+ 104 - 0
service/codex_credential_refresh.go

@@ -0,0 +1,104 @@
+package service
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/model"
+)
+
+type CodexCredentialRefreshOptions struct {
+	ResetCaches bool
+}
+
+type CodexOAuthKey struct {
+	IDToken      string `json:"id_token,omitempty"`
+	AccessToken  string `json:"access_token,omitempty"`
+	RefreshToken string `json:"refresh_token,omitempty"`
+
+	AccountID   string `json:"account_id,omitempty"`
+	LastRefresh string `json:"last_refresh,omitempty"`
+	Email       string `json:"email,omitempty"`
+	Type        string `json:"type,omitempty"`
+	Expired     string `json:"expired,omitempty"`
+}
+
+func parseCodexOAuthKey(raw string) (*CodexOAuthKey, error) {
+	if strings.TrimSpace(raw) == "" {
+		return nil, errors.New("codex channel: empty oauth key")
+	}
+	var key CodexOAuthKey
+	if err := common.Unmarshal([]byte(raw), &key); err != nil {
+		return nil, errors.New("codex channel: invalid oauth key json")
+	}
+	return &key, nil
+}
+
+func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts CodexCredentialRefreshOptions) (*CodexOAuthKey, *model.Channel, error) {
+	ch, err := model.GetChannelById(channelID, true)
+	if err != nil {
+		return nil, nil, err
+	}
+	if ch == nil {
+		return nil, nil, fmt.Errorf("channel not found")
+	}
+	if ch.Type != constant.ChannelTypeCodex {
+		return nil, nil, fmt.Errorf("channel type is not Codex")
+	}
+
+	oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(ch.Key))
+	if err != nil {
+		return nil, nil, err
+	}
+	if strings.TrimSpace(oauthKey.RefreshToken) == "" {
+		return nil, nil, fmt.Errorf("codex channel: refresh_token is required to refresh credential")
+	}
+
+	refreshCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
+
+	res, err := RefreshCodexOAuthToken(refreshCtx, oauthKey.RefreshToken)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	oauthKey.AccessToken = res.AccessToken
+	oauthKey.RefreshToken = res.RefreshToken
+	oauthKey.LastRefresh = time.Now().Format(time.RFC3339)
+	oauthKey.Expired = res.ExpiresAt.Format(time.RFC3339)
+	if strings.TrimSpace(oauthKey.Type) == "" {
+		oauthKey.Type = "codex"
+	}
+
+	if strings.TrimSpace(oauthKey.AccountID) == "" {
+		if accountID, ok := ExtractCodexAccountIDFromJWT(oauthKey.AccessToken); ok {
+			oauthKey.AccountID = accountID
+		}
+	}
+	if strings.TrimSpace(oauthKey.Email) == "" {
+		if email, ok := ExtractEmailFromJWT(oauthKey.AccessToken); ok {
+			oauthKey.Email = email
+		}
+	}
+
+	encoded, err := common.Marshal(oauthKey)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if err := model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error; err != nil {
+		return nil, nil, err
+	}
+
+	if opts.ResetCaches {
+		model.InitChannelCache()
+		ResetProxyClientCache()
+	}
+
+	return oauthKey, ch, nil
+}

+ 140 - 0
service/codex_credential_refresh_task.go

@@ -0,0 +1,140 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/constant"
+	"github.com/QuantumNous/new-api/logger"
+	"github.com/QuantumNous/new-api/model"
+
+	"github.com/bytedance/gopkg/util/gopool"
+)
+
+const (
+	codexCredentialRefreshTickInterval = 10 * time.Minute
+	codexCredentialRefreshThreshold    = 24 * time.Hour
+	codexCredentialRefreshBatchSize    = 200
+	codexCredentialRefreshTimeout      = 15 * time.Second
+)
+
+var (
+	codexCredentialRefreshOnce    sync.Once
+	codexCredentialRefreshRunning atomic.Bool
+)
+
+func StartCodexCredentialAutoRefreshTask() {
+	codexCredentialRefreshOnce.Do(func() {
+		if !common.IsMasterNode {
+			return
+		}
+
+		gopool.Go(func() {
+			logger.LogInfo(context.Background(), fmt.Sprintf("codex credential auto-refresh task started: tick=%s threshold=%s", codexCredentialRefreshTickInterval, codexCredentialRefreshThreshold))
+
+			ticker := time.NewTicker(codexCredentialRefreshTickInterval)
+			defer ticker.Stop()
+
+			runCodexCredentialAutoRefreshOnce()
+			for range ticker.C {
+				runCodexCredentialAutoRefreshOnce()
+			}
+		})
+	})
+}
+
+func runCodexCredentialAutoRefreshOnce() {
+	if !codexCredentialRefreshRunning.CompareAndSwap(false, true) {
+		return
+	}
+	defer codexCredentialRefreshRunning.Store(false)
+
+	ctx := context.Background()
+	now := time.Now()
+
+	var refreshed int
+	var scanned int
+
+	offset := 0
+	for {
+		var channels []*model.Channel
+		err := model.DB.
+			Select("id", "name", "key", "status", "channel_info").
+			Where("type = ? AND status = 1", constant.ChannelTypeCodex).
+			Order("id asc").
+			Limit(codexCredentialRefreshBatchSize).
+			Offset(offset).
+			Find(&channels).Error
+		if err != nil {
+			logger.LogError(ctx, fmt.Sprintf("codex credential auto-refresh: query channels failed: %v", err))
+			return
+		}
+		if len(channels) == 0 {
+			break
+		}
+		offset += codexCredentialRefreshBatchSize
+
+		for _, ch := range channels {
+			if ch == nil {
+				continue
+			}
+			scanned++
+			if ch.ChannelInfo.IsMultiKey {
+				continue
+			}
+
+			rawKey := strings.TrimSpace(ch.Key)
+			if rawKey == "" {
+				continue
+			}
+
+			oauthKey, err := parseCodexOAuthKey(rawKey)
+			if err != nil {
+				continue
+			}
+
+			refreshToken := strings.TrimSpace(oauthKey.RefreshToken)
+			if refreshToken == "" {
+				continue
+			}
+
+			expiredAtRaw := strings.TrimSpace(oauthKey.Expired)
+			expiredAt, err := time.Parse(time.RFC3339, expiredAtRaw)
+			if err == nil && !expiredAt.IsZero() && expiredAt.Sub(now) > codexCredentialRefreshThreshold {
+				continue
+			}
+
+			refreshCtx, cancel := context.WithTimeout(ctx, codexCredentialRefreshTimeout)
+			newKey, _, err := RefreshCodexChannelCredential(refreshCtx, ch.Id, CodexCredentialRefreshOptions{ResetCaches: false})
+			cancel()
+			if err != nil {
+				logger.LogWarn(ctx, fmt.Sprintf("codex credential auto-refresh: channel_id=%d name=%s refresh failed: %v", ch.Id, ch.Name, err))
+				continue
+			}
+
+			refreshed++
+			logger.LogInfo(ctx, fmt.Sprintf("codex credential auto-refresh: channel_id=%d name=%s refreshed, expires_at=%s", ch.Id, ch.Name, newKey.Expired))
+		}
+	}
+
+	if refreshed > 0 {
+		func() {
+			defer func() {
+				if r := recover(); r != nil {
+					logger.LogWarn(ctx, fmt.Sprintf("codex credential auto-refresh: InitChannelCache panic: %v", r))
+				}
+			}()
+			model.InitChannelCache()
+		}()
+		ResetProxyClientCache()
+	}
+
+	if common.DebugEnabled {
+		logger.LogDebug(ctx, "codex credential auto-refresh: scanned=%d refreshed=%d", scanned, refreshed)
+	}
+}

+ 288 - 0
service/codex_oauth.go

@@ -0,0 +1,288 @@
+package service
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+const (
+	codexOAuthClientID     = "app_EMoamEEZ73f0CkXaXp7hrann"
+	codexOAuthAuthorizeURL = "https://auth.openai.com/oauth/authorize"
+	codexOAuthTokenURL     = "https://auth.openai.com/oauth/token"
+	codexOAuthRedirectURI  = "http://localhost:1455/auth/callback"
+	codexOAuthScope        = "openid profile email offline_access"
+	codexJWTClaimPath      = "https://api.openai.com/auth"
+	defaultHTTPTimeout     = 20 * time.Second
+)
+
+type CodexOAuthTokenResult struct {
+	AccessToken  string
+	RefreshToken string
+	ExpiresAt    time.Time
+}
+
+type CodexOAuthAuthorizationFlow struct {
+	State        string
+	Verifier     string
+	Challenge    string
+	AuthorizeURL string
+}
+
+func RefreshCodexOAuthToken(ctx context.Context, refreshToken string) (*CodexOAuthTokenResult, error) {
+	client := &http.Client{Timeout: defaultHTTPTimeout}
+	return refreshCodexOAuthToken(ctx, client, codexOAuthTokenURL, codexOAuthClientID, refreshToken)
+}
+
+func ExchangeCodexAuthorizationCode(ctx context.Context, code string, verifier string) (*CodexOAuthTokenResult, error) {
+	client := &http.Client{Timeout: defaultHTTPTimeout}
+	return exchangeCodexAuthorizationCode(ctx, client, codexOAuthTokenURL, codexOAuthClientID, code, verifier, codexOAuthRedirectURI)
+}
+
+func CreateCodexOAuthAuthorizationFlow() (*CodexOAuthAuthorizationFlow, error) {
+	state, err := createStateHex(16)
+	if err != nil {
+		return nil, err
+	}
+	verifier, challenge, err := generatePKCEPair()
+	if err != nil {
+		return nil, err
+	}
+	u, err := buildCodexAuthorizeURL(state, challenge)
+	if err != nil {
+		return nil, err
+	}
+	return &CodexOAuthAuthorizationFlow{
+		State:        state,
+		Verifier:     verifier,
+		Challenge:    challenge,
+		AuthorizeURL: u,
+	}, nil
+}
+
+func refreshCodexOAuthToken(
+	ctx context.Context,
+	client *http.Client,
+	tokenURL string,
+	clientID string,
+	refreshToken string,
+) (*CodexOAuthTokenResult, error) {
+	rt := strings.TrimSpace(refreshToken)
+	if rt == "" {
+		return nil, errors.New("empty refresh_token")
+	}
+
+	form := url.Values{}
+	form.Set("grant_type", "refresh_token")
+	form.Set("refresh_token", rt)
+	form.Set("client_id", clientID)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	var payload struct {
+		AccessToken  string `json:"access_token"`
+		RefreshToken string `json:"refresh_token"`
+		ExpiresIn    int    `json:"expires_in"`
+	}
+
+	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+		return nil, err
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("codex oauth refresh failed: status=%d", resp.StatusCode)
+	}
+
+	if strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" || payload.ExpiresIn <= 0 {
+		return nil, errors.New("codex oauth refresh response missing fields")
+	}
+
+	return &CodexOAuthTokenResult{
+		AccessToken:  strings.TrimSpace(payload.AccessToken),
+		RefreshToken: strings.TrimSpace(payload.RefreshToken),
+		ExpiresAt:    time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second),
+	}, nil
+}
+
+func exchangeCodexAuthorizationCode(
+	ctx context.Context,
+	client *http.Client,
+	tokenURL string,
+	clientID string,
+	code string,
+	verifier string,
+	redirectURI string,
+) (*CodexOAuthTokenResult, error) {
+	c := strings.TrimSpace(code)
+	v := strings.TrimSpace(verifier)
+	if c == "" {
+		return nil, errors.New("empty authorization code")
+	}
+	if v == "" {
+		return nil, errors.New("empty code_verifier")
+	}
+
+	form := url.Values{}
+	form.Set("grant_type", "authorization_code")
+	form.Set("client_id", clientID)
+	form.Set("code", c)
+	form.Set("code_verifier", v)
+	form.Set("redirect_uri", redirectURI)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	var payload struct {
+		AccessToken  string `json:"access_token"`
+		RefreshToken string `json:"refresh_token"`
+		ExpiresIn    int    `json:"expires_in"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+		return nil, err
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, fmt.Errorf("codex oauth code exchange failed: status=%d", resp.StatusCode)
+	}
+	if strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" || payload.ExpiresIn <= 0 {
+		return nil, errors.New("codex oauth token response missing fields")
+	}
+	return &CodexOAuthTokenResult{
+		AccessToken:  strings.TrimSpace(payload.AccessToken),
+		RefreshToken: strings.TrimSpace(payload.RefreshToken),
+		ExpiresAt:    time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second),
+	}, nil
+}
+
+func buildCodexAuthorizeURL(state string, challenge string) (string, error) {
+	u, err := url.Parse(codexOAuthAuthorizeURL)
+	if err != nil {
+		return "", err
+	}
+	q := u.Query()
+	q.Set("response_type", "code")
+	q.Set("client_id", codexOAuthClientID)
+	q.Set("redirect_uri", codexOAuthRedirectURI)
+	q.Set("scope", codexOAuthScope)
+	q.Set("code_challenge", challenge)
+	q.Set("code_challenge_method", "S256")
+	q.Set("state", state)
+	q.Set("id_token_add_organizations", "true")
+	q.Set("codex_cli_simplified_flow", "true")
+	q.Set("originator", "codex_cli_rs")
+	u.RawQuery = q.Encode()
+	return u.String(), nil
+}
+
+func createStateHex(nBytes int) (string, error) {
+	if nBytes <= 0 {
+		return "", errors.New("invalid state bytes length")
+	}
+	b := make([]byte, nBytes)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("%x", b), nil
+}
+
+func generatePKCEPair() (verifier string, challenge string, err error) {
+	b := make([]byte, 32)
+	if _, err := rand.Read(b); err != nil {
+		return "", "", err
+	}
+	verifier = base64.RawURLEncoding.EncodeToString(b)
+	sum := sha256.Sum256([]byte(verifier))
+	challenge = base64.RawURLEncoding.EncodeToString(sum[:])
+	return verifier, challenge, nil
+}
+
+func ExtractCodexAccountIDFromJWT(token string) (string, bool) {
+	claims, ok := decodeJWTClaims(token)
+	if !ok {
+		return "", false
+	}
+	raw, ok := claims[codexJWTClaimPath]
+	if !ok {
+		return "", false
+	}
+	obj, ok := raw.(map[string]any)
+	if !ok {
+		return "", false
+	}
+	v, ok := obj["chatgpt_account_id"]
+	if !ok {
+		return "", false
+	}
+	s, ok := v.(string)
+	if !ok {
+		return "", false
+	}
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return "", false
+	}
+	return s, true
+}
+
+func ExtractEmailFromJWT(token string) (string, bool) {
+	claims, ok := decodeJWTClaims(token)
+	if !ok {
+		return "", false
+	}
+	v, ok := claims["email"]
+	if !ok {
+		return "", false
+	}
+	s, ok := v.(string)
+	if !ok {
+		return "", false
+	}
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return "", false
+	}
+	return s, true
+}
+
+func decodeJWTClaims(token string) (map[string]any, bool) {
+	parts := strings.Split(token, ".")
+	if len(parts) != 3 {
+		return nil, false
+	}
+	payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
+	if err != nil {
+		return nil, false
+	}
+	var claims map[string]any
+	if err := json.Unmarshal(payloadRaw, &claims); err != nil {
+		return nil, false
+	}
+	return claims, true
+}

+ 56 - 0
service/codex_wham_usage.go

@@ -0,0 +1,56 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+)
+
+func FetchCodexWhamUsage(
+	ctx context.Context,
+	client *http.Client,
+	baseURL string,
+	accessToken string,
+	accountID string,
+) (statusCode int, body []byte, err error) {
+	if client == nil {
+		return 0, nil, fmt.Errorf("nil http client")
+	}
+	bu := strings.TrimRight(strings.TrimSpace(baseURL), "/")
+	if bu == "" {
+		return 0, nil, fmt.Errorf("empty baseURL")
+	}
+	at := strings.TrimSpace(accessToken)
+	aid := strings.TrimSpace(accountID)
+	if at == "" {
+		return 0, nil, fmt.Errorf("empty accessToken")
+	}
+	if aid == "" {
+		return 0, nil, fmt.Errorf("empty accountID")
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, bu+"/backend-api/wham/usage", nil)
+	if err != nil {
+		return 0, nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+at)
+	req.Header.Set("chatgpt-account-id", aid)
+	req.Header.Set("Accept", "application/json")
+	if req.Header.Get("originator") == "" {
+		req.Header.Set("originator", "codex_cli_rs")
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return 0, nil, err
+	}
+	defer resp.Body.Close()
+
+	body, err = io.ReadAll(resp.Body)
+	if err != nil {
+		return resp.StatusCode, nil, err
+	}
+	return resp.StatusCode, body, nil
+}

+ 24 - 15
service/http_client.go

@@ -40,6 +40,9 @@ func InitHttpClient() {
 		ForceAttemptHTTP2:   true,
 		Proxy:               http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars
 	}
+	if common.TLSInsecureSkipVerify {
+		transport.TLSClientConfig = common.InsecureTLSConfig
+	}
 
 	if common.RelayTimeout == 0 {
 		httpClient = &http.Client{
@@ -102,13 +105,17 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
 
 	switch parsedURL.Scheme {
 	case "http", "https":
+		transport := &http.Transport{
+			MaxIdleConns:        common.RelayMaxIdleConns,
+			MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
+			ForceAttemptHTTP2:   true,
+			Proxy:               http.ProxyURL(parsedURL),
+		}
+		if common.TLSInsecureSkipVerify {
+			transport.TLSClientConfig = common.InsecureTLSConfig
+		}
 		client := &http.Client{
-			Transport: &http.Transport{
-				MaxIdleConns:        common.RelayMaxIdleConns,
-				MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
-				ForceAttemptHTTP2:   true,
-				Proxy:               http.ProxyURL(parsedURL),
-			},
+			Transport:     transport,
 			CheckRedirect: checkRedirect,
 		}
 		client.Timeout = time.Duration(common.RelayTimeout) * time.Second
@@ -137,17 +144,19 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
 			return nil, err
 		}
 
-		client := &http.Client{
-			Transport: &http.Transport{
-				MaxIdleConns:        common.RelayMaxIdleConns,
-				MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
-				ForceAttemptHTTP2:   true,
-				DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
-					return dialer.Dial(network, addr)
-				},
+		transport := &http.Transport{
+			MaxIdleConns:        common.RelayMaxIdleConns,
+			MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
+			ForceAttemptHTTP2:   true,
+			DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+				return dialer.Dial(network, addr)
 			},
-			CheckRedirect: checkRedirect,
 		}
+		if common.TLSInsecureSkipVerify {
+			transport.TLSClientConfig = common.InsecureTLSConfig
+		}
+
+		client := &http.Client{Transport: transport, CheckRedirect: checkRedirect}
 		client.Timeout = time.Duration(common.RelayTimeout) * time.Second
 		proxyClientLock.Lock()
 		proxyClients[proxyURL] = client

+ 29 - 0
service/log_info_generate.go

@@ -70,9 +70,38 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
 
 	other["admin_info"] = adminInfo
 	appendRequestPath(ctx, relayInfo, other)
+	appendRequestConversionChain(relayInfo, other)
 	return other
 }
 
+func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
+	if relayInfo == nil || other == nil {
+		return
+	}
+	if len(relayInfo.RequestConversionChain) == 0 {
+		return
+	}
+	chain := make([]string, 0, len(relayInfo.RequestConversionChain))
+	for _, f := range relayInfo.RequestConversionChain {
+		switch f {
+		case types.RelayFormatOpenAI:
+			chain = append(chain, "OpenAI Compatible")
+		case types.RelayFormatClaude:
+			chain = append(chain, "Claude Messages")
+		case types.RelayFormatGemini:
+			chain = append(chain, "Google Gemini")
+		case types.RelayFormatOpenAIResponses:
+			chain = append(chain, "OpenAI Responses")
+		default:
+			chain = append(chain, string(f))
+		}
+	}
+	if len(chain) == 0 {
+		return
+	}
+	other["request_conversion"] = chain
+}
+
 func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
 	info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
 	info["ws"] = true

+ 18 - 0
service/openai_chat_responses_compat.go

@@ -0,0 +1,18 @@
+package service
+
+import (
+	"github.com/QuantumNous/new-api/dto"
+	"github.com/QuantumNous/new-api/service/openaicompat"
+)
+
+func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
+	return openaicompat.ChatCompletionsRequestToResponsesRequest(req)
+}
+
+func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
+	return openaicompat.ResponsesResponseToChatCompletionsResponse(resp, id)
+}
+
+func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
+	return openaicompat.ExtractOutputTextFromResponses(resp)
+}

+ 14 - 0
service/openai_chat_responses_mode.go

@@ -0,0 +1,14 @@
+package service
+
+import (
+	"github.com/QuantumNous/new-api/service/openaicompat"
+	"github.com/QuantumNous/new-api/setting/model_setting"
+)
+
+func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, model string) bool {
+	return openaicompat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, model)
+}
+
+func ShouldChatCompletionsUseResponsesGlobal(channelID int, model string) bool {
+	return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, model)
+}

+ 356 - 0
service/openaicompat/chat_to_responses.go

@@ -0,0 +1,356 @@
+package openaicompat
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/dto"
+)
+
+func normalizeChatImageURLToString(v any) any {
+	switch vv := v.(type) {
+	case string:
+		return vv
+	case map[string]any:
+		if url := common.Interface2String(vv["url"]); url != "" {
+			return url
+		}
+		return v
+	case dto.MessageImageUrl:
+		if vv.Url != "" {
+			return vv.Url
+		}
+		return v
+	case *dto.MessageImageUrl:
+		if vv != nil && vv.Url != "" {
+			return vv.Url
+		}
+		return v
+	default:
+		return v
+	}
+}
+
+func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
+	if req == nil {
+		return nil, errors.New("request is nil")
+	}
+	if req.Model == "" {
+		return nil, errors.New("model is required")
+	}
+	if req.N > 1 {
+		return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode")
+	}
+
+	var instructionsParts []string
+	inputItems := make([]map[string]any, 0, len(req.Messages))
+
+	for _, msg := range req.Messages {
+		role := strings.TrimSpace(msg.Role)
+		if role == "" {
+			continue
+		}
+
+		if role == "tool" || role == "function" {
+			callID := strings.TrimSpace(msg.ToolCallId)
+
+			var output any
+			if msg.Content == nil {
+				output = ""
+			} else if msg.IsStringContent() {
+				output = msg.StringContent()
+			} else {
+				if b, err := common.Marshal(msg.Content); err == nil {
+					output = string(b)
+				} else {
+					output = fmt.Sprintf("%v", msg.Content)
+				}
+			}
+
+			if callID == "" {
+				inputItems = append(inputItems, map[string]any{
+					"role":    "user",
+					"content": fmt.Sprintf("[tool_output_missing_call_id] %v", output),
+				})
+				continue
+			}
+
+			inputItems = append(inputItems, map[string]any{
+				"type":    "function_call_output",
+				"call_id": callID,
+				"output":  output,
+			})
+			continue
+		}
+
+		// Prefer mapping system/developer messages into `instructions`.
+		if role == "system" || role == "developer" {
+			if msg.Content == nil {
+				continue
+			}
+			if msg.IsStringContent() {
+				if s := strings.TrimSpace(msg.StringContent()); s != "" {
+					instructionsParts = append(instructionsParts, s)
+				}
+				continue
+			}
+			parts := msg.ParseContent()
+			var sb strings.Builder
+			for _, part := range parts {
+				if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" {
+					if sb.Len() > 0 {
+						sb.WriteString("\n")
+					}
+					sb.WriteString(part.Text)
+				}
+			}
+			if s := strings.TrimSpace(sb.String()); s != "" {
+				instructionsParts = append(instructionsParts, s)
+			}
+			continue
+		}
+
+		item := map[string]any{
+			"role": role,
+		}
+
+		if msg.Content == nil {
+			item["content"] = ""
+			inputItems = append(inputItems, item)
+
+			if role == "assistant" {
+				for _, tc := range msg.ParseToolCalls() {
+					if strings.TrimSpace(tc.ID) == "" {
+						continue
+					}
+					if tc.Type != "" && tc.Type != "function" {
+						continue
+					}
+					name := strings.TrimSpace(tc.Function.Name)
+					if name == "" {
+						continue
+					}
+					inputItems = append(inputItems, map[string]any{
+						"type":      "function_call",
+						"call_id":   tc.ID,
+						"name":      name,
+						"arguments": tc.Function.Arguments,
+					})
+				}
+			}
+			continue
+		}
+
+		if msg.IsStringContent() {
+			item["content"] = msg.StringContent()
+			inputItems = append(inputItems, item)
+
+			if role == "assistant" {
+				for _, tc := range msg.ParseToolCalls() {
+					if strings.TrimSpace(tc.ID) == "" {
+						continue
+					}
+					if tc.Type != "" && tc.Type != "function" {
+						continue
+					}
+					name := strings.TrimSpace(tc.Function.Name)
+					if name == "" {
+						continue
+					}
+					inputItems = append(inputItems, map[string]any{
+						"type":      "function_call",
+						"call_id":   tc.ID,
+						"name":      name,
+						"arguments": tc.Function.Arguments,
+					})
+				}
+			}
+			continue
+		}
+
+		parts := msg.ParseContent()
+		contentParts := make([]map[string]any, 0, len(parts))
+		for _, part := range parts {
+			switch part.Type {
+			case dto.ContentTypeText:
+				contentParts = append(contentParts, map[string]any{
+					"type": "input_text",
+					"text": part.Text,
+				})
+			case dto.ContentTypeImageURL:
+				contentParts = append(contentParts, map[string]any{
+					"type":      "input_image",
+					"image_url": normalizeChatImageURLToString(part.ImageUrl),
+				})
+			case dto.ContentTypeInputAudio:
+				contentParts = append(contentParts, map[string]any{
+					"type":        "input_audio",
+					"input_audio": part.InputAudio,
+				})
+			case dto.ContentTypeFile:
+				contentParts = append(contentParts, map[string]any{
+					"type": "input_file",
+					"file": part.File,
+				})
+			case dto.ContentTypeVideoUrl:
+				contentParts = append(contentParts, map[string]any{
+					"type":      "input_video",
+					"video_url": part.VideoUrl,
+				})
+			default:
+				contentParts = append(contentParts, map[string]any{
+					"type": part.Type,
+				})
+			}
+		}
+		item["content"] = contentParts
+		inputItems = append(inputItems, item)
+
+		if role == "assistant" {
+			for _, tc := range msg.ParseToolCalls() {
+				if strings.TrimSpace(tc.ID) == "" {
+					continue
+				}
+				if tc.Type != "" && tc.Type != "function" {
+					continue
+				}
+				name := strings.TrimSpace(tc.Function.Name)
+				if name == "" {
+					continue
+				}
+				inputItems = append(inputItems, map[string]any{
+					"type":      "function_call",
+					"call_id":   tc.ID,
+					"name":      name,
+					"arguments": tc.Function.Arguments,
+				})
+			}
+		}
+	}
+
+	inputRaw, err := common.Marshal(inputItems)
+	if err != nil {
+		return nil, err
+	}
+
+	var instructionsRaw json.RawMessage
+	if len(instructionsParts) > 0 {
+		instructions := strings.Join(instructionsParts, "\n\n")
+		instructionsRaw, _ = common.Marshal(instructions)
+	}
+
+	var toolsRaw json.RawMessage
+	if req.Tools != nil {
+		tools := make([]map[string]any, 0, len(req.Tools))
+		for _, tool := range req.Tools {
+			switch tool.Type {
+			case "function":
+				tools = append(tools, map[string]any{
+					"type":        "function",
+					"name":        tool.Function.Name,
+					"description": tool.Function.Description,
+					"parameters":  tool.Function.Parameters,
+				})
+			default:
+				// Best-effort: keep original tool shape for unknown types.
+				var m map[string]any
+				if b, err := common.Marshal(tool); err == nil {
+					_ = common.Unmarshal(b, &m)
+				}
+				if len(m) == 0 {
+					m = map[string]any{"type": tool.Type}
+				}
+				tools = append(tools, m)
+			}
+		}
+		toolsRaw, _ = common.Marshal(tools)
+	}
+
+	var toolChoiceRaw json.RawMessage
+	if req.ToolChoice != nil {
+		switch v := req.ToolChoice.(type) {
+		case string:
+			toolChoiceRaw, _ = common.Marshal(v)
+		default:
+			var m map[string]any
+			if b, err := common.Marshal(v); err == nil {
+				_ = common.Unmarshal(b, &m)
+			}
+			if m == nil {
+				toolChoiceRaw, _ = common.Marshal(v)
+			} else if t, _ := m["type"].(string); t == "function" {
+				// Chat: {"type":"function","function":{"name":"..."}}
+				// Responses: {"type":"function","name":"..."}
+				if name, ok := m["name"].(string); ok && name != "" {
+					toolChoiceRaw, _ = common.Marshal(map[string]any{
+						"type": "function",
+						"name": name,
+					})
+				} else if fn, ok := m["function"].(map[string]any); ok {
+					if name, ok := fn["name"].(string); ok && name != "" {
+						toolChoiceRaw, _ = common.Marshal(map[string]any{
+							"type": "function",
+							"name": name,
+						})
+					} else {
+						toolChoiceRaw, _ = common.Marshal(v)
+					}
+				} else {
+					toolChoiceRaw, _ = common.Marshal(v)
+				}
+			} else {
+				toolChoiceRaw, _ = common.Marshal(v)
+			}
+		}
+	}
+
+	var parallelToolCallsRaw json.RawMessage
+	if req.ParallelTooCalls != nil {
+		parallelToolCallsRaw, _ = common.Marshal(*req.ParallelTooCalls)
+	}
+
+	var textRaw json.RawMessage
+	if req.ResponseFormat != nil && req.ResponseFormat.Type != "" {
+		textRaw, _ = common.Marshal(map[string]any{
+			"format": req.ResponseFormat,
+		})
+	}
+
+	maxOutputTokens := req.MaxTokens
+	if req.MaxCompletionTokens > maxOutputTokens {
+		maxOutputTokens = req.MaxCompletionTokens
+	}
+
+	var topP *float64
+	if req.TopP != 0 {
+		topP = common.GetPointer(req.TopP)
+	}
+
+	out := &dto.OpenAIResponsesRequest{
+		Model:             req.Model,
+		Input:             inputRaw,
+		Instructions:      instructionsRaw,
+		MaxOutputTokens:   maxOutputTokens,
+		Stream:            req.Stream,
+		Temperature:       req.Temperature,
+		Text:              textRaw,
+		ToolChoice:        toolChoiceRaw,
+		Tools:             toolsRaw,
+		TopP:              topP,
+		User:              req.User,
+		ParallelToolCalls: parallelToolCallsRaw,
+		Store:             req.Store,
+		Metadata:          req.Metadata,
+	}
+
+	if req.ReasoningEffort != "" && req.ReasoningEffort != "none" {
+		out.Reasoning = &dto.Reasoning{
+			Effort: req.ReasoningEffort,
+		}
+	}
+
+	return out, nil
+}

+ 18 - 0
service/openaicompat/policy.go

@@ -0,0 +1,18 @@
+package openaicompat
+
+import "github.com/QuantumNous/new-api/setting/model_setting"
+
+func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, model string) bool {
+	if !policy.IsChannelEnabled(channelID) {
+		return false
+	}
+	return matchAnyRegex(policy.ModelPatterns, model)
+}
+
+func ShouldChatCompletionsUseResponsesGlobal(channelID int, model string) bool {
+	return ShouldChatCompletionsUseResponsesPolicy(
+		model_setting.GetGlobalSettings().ChatCompletionsToResponsesPolicy,
+		channelID,
+		model,
+	)
+}

+ 33 - 0
service/openaicompat/regex.go

@@ -0,0 +1,33 @@
+package openaicompat
+
+import (
+	"regexp"
+	"sync"
+)
+
+var compiledRegexCache sync.Map // map[string]*regexp.Regexp
+
+func matchAnyRegex(patterns []string, s string) bool {
+	if len(patterns) == 0 || s == "" {
+		return false
+	}
+	for _, pattern := range patterns {
+		if pattern == "" {
+			continue
+		}
+		re, ok := compiledRegexCache.Load(pattern)
+		if !ok {
+			compiled, err := regexp.Compile(pattern)
+			if err != nil {
+				// Treat invalid patterns as non-matching to avoid breaking runtime traffic.
+				continue
+			}
+			re = compiled
+			compiledRegexCache.Store(pattern, re)
+		}
+		if re.(*regexp.Regexp).MatchString(s) {
+			return true
+		}
+	}
+	return false
+}

+ 133 - 0
service/openaicompat/responses_to_chat.go

@@ -0,0 +1,133 @@
+package openaicompat
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/QuantumNous/new-api/dto"
+)
+
+func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
+	if resp == nil {
+		return nil, nil, errors.New("response is nil")
+	}
+
+	text := ExtractOutputTextFromResponses(resp)
+
+	usage := &dto.Usage{}
+	if resp.Usage != nil {
+		if resp.Usage.InputTokens != 0 {
+			usage.PromptTokens = resp.Usage.InputTokens
+			usage.InputTokens = resp.Usage.InputTokens
+		}
+		if resp.Usage.OutputTokens != 0 {
+			usage.CompletionTokens = resp.Usage.OutputTokens
+			usage.OutputTokens = resp.Usage.OutputTokens
+		}
+		if resp.Usage.TotalTokens != 0 {
+			usage.TotalTokens = resp.Usage.TotalTokens
+		} else {
+			usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+		}
+		if resp.Usage.InputTokensDetails != nil {
+			usage.PromptTokensDetails.CachedTokens = resp.Usage.InputTokensDetails.CachedTokens
+			usage.PromptTokensDetails.ImageTokens = resp.Usage.InputTokensDetails.ImageTokens
+			usage.PromptTokensDetails.AudioTokens = resp.Usage.InputTokensDetails.AudioTokens
+		}
+		if resp.Usage.CompletionTokenDetails.ReasoningTokens != 0 {
+			usage.CompletionTokenDetails.ReasoningTokens = resp.Usage.CompletionTokenDetails.ReasoningTokens
+		}
+	}
+
+	created := resp.CreatedAt
+
+	var toolCalls []dto.ToolCallResponse
+	if text == "" && len(resp.Output) > 0 {
+		for _, out := range resp.Output {
+			if out.Type != "function_call" {
+				continue
+			}
+			name := strings.TrimSpace(out.Name)
+			if name == "" {
+				continue
+			}
+			callId := strings.TrimSpace(out.CallId)
+			if callId == "" {
+				callId = strings.TrimSpace(out.ID)
+			}
+			toolCalls = append(toolCalls, dto.ToolCallResponse{
+				ID:   callId,
+				Type: "function",
+				Function: dto.FunctionResponse{
+					Name:      name,
+					Arguments: out.Arguments,
+				},
+			})
+		}
+	}
+
+	finishReason := "stop"
+	if len(toolCalls) > 0 {
+		finishReason = "tool_calls"
+	}
+
+	msg := dto.Message{
+		Role:    "assistant",
+		Content: text,
+	}
+	if len(toolCalls) > 0 {
+		msg.SetToolCalls(toolCalls)
+		msg.Content = ""
+	}
+
+	out := &dto.OpenAITextResponse{
+		Id:      id,
+		Object:  "chat.completion",
+		Created: created,
+		Model:   resp.Model,
+		Choices: []dto.OpenAITextResponseChoice{
+			{
+				Index:        0,
+				Message:      msg,
+				FinishReason: finishReason,
+			},
+		},
+		Usage: *usage,
+	}
+
+	return out, usage, nil
+}
+
+func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
+	if resp == nil || len(resp.Output) == 0 {
+		return ""
+	}
+
+	var sb strings.Builder
+
+	// Prefer assistant message outputs.
+	for _, out := range resp.Output {
+		if out.Type != "message" {
+			continue
+		}
+		if out.Role != "" && out.Role != "assistant" {
+			continue
+		}
+		for _, c := range out.Content {
+			if c.Type == "output_text" && c.Text != "" {
+				sb.WriteString(c.Text)
+			}
+		}
+	}
+	if sb.Len() > 0 {
+		return sb.String()
+	}
+	for _, out := range resp.Output {
+		for _, c := range out.Content {
+			if c.Text != "" {
+				sb.WriteString(c.Text)
+			}
+		}
+	}
+	return sb.String()
+}

+ 28 - 2
setting/model_setting/global.go

@@ -1,14 +1,36 @@
 package model_setting
 
 import (
+	"slices"
 	"strings"
 
 	"github.com/QuantumNous/new-api/setting/config"
 )
 
+type ChatCompletionsToResponsesPolicy struct {
+	Enabled       bool     `json:"enabled"`
+	AllChannels   bool     `json:"all_channels"`
+	ChannelIDs    []int    `json:"channel_ids,omitempty"`
+	ModelPatterns []string `json:"model_patterns,omitempty"`
+}
+
+func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int) bool {
+	if !p.Enabled {
+		return false
+	}
+	if p.AllChannels {
+		return true
+	}
+	if channelID == 0 || len(p.ChannelIDs) == 0 {
+		return false
+	}
+	return slices.Contains(p.ChannelIDs, channelID)
+}
+
 type GlobalSettings struct {
-	PassThroughRequestEnabled bool     `json:"pass_through_request_enabled"`
-	ThinkingModelBlacklist    []string `json:"thinking_model_blacklist"`
+	PassThroughRequestEnabled        bool                             `json:"pass_through_request_enabled"`
+	ThinkingModelBlacklist           []string                         `json:"thinking_model_blacklist"`
+	ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"`
 }
 
 // 默认配置
@@ -18,6 +40,10 @@ var defaultOpenaiSettings = GlobalSettings{
 		"moonshotai/kimi-k2-thinking",
 		"kimi-k2-thinking",
 	},
+	ChatCompletionsToResponsesPolicy: ChatCompletionsToResponsesPolicy{
+		Enabled:     false,
+		AllChannels: true,
+	},
 }
 
 // 全局实例

+ 50 - 0
setting/model_setting/qwen.go

@@ -0,0 +1,50 @@
+package model_setting
+
+import (
+	"strings"
+
+	"github.com/QuantumNous/new-api/setting/config"
+)
+
+// QwenSettings defines Qwen model configuration. 注意bool要以enabled结尾才可以生效编辑
+type QwenSettings struct {
+	SyncImageModels []string `json:"sync_image_models"`
+}
+
+// 默认配置
+var defaultQwenSettings = QwenSettings{
+	SyncImageModels: []string{
+		"z-image",
+		"qwen-image",
+		"wan2.6",
+		"qwen-image-edit",
+		"qwen-image-edit-max",
+		"qwen-image-edit-max-2026-01-16",
+		"qwen-image-edit-plus",
+		"qwen-image-edit-plus-2025-12-15",
+		"qwen-image-edit-plus-2025-10-30",
+	},
+}
+
+// 全局实例
+var qwenSettings = defaultQwenSettings
+
+func init() {
+	// 注册到全局配置管理器
+	config.GlobalConfig.Register("qwen", &qwenSettings)
+}
+
+// GetQwenSettings
+func GetQwenSettings() *QwenSettings {
+	return &qwenSettings
+}
+
+// IsSyncImageModel
+func IsSyncImageModel(model string) bool {
+	for _, m := range qwenSettings.SyncImageModels {
+		if strings.Contains(model, m) {
+			return true
+		}
+	}
+	return false
+}

+ 184 - 0
setting/operation_setting/status_code_ranges.go

@@ -0,0 +1,184 @@
+package operation_setting
+
+import (
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+type StatusCodeRange struct {
+	Start int
+	End   int
+}
+
+var AutomaticDisableStatusCodeRanges = []StatusCodeRange{{Start: 401, End: 401}}
+
+// Default behavior matches legacy hardcoded retry rules in controller/relay.go shouldRetry:
+// retry for 1xx, 3xx, 4xx(except 400/408), 5xx(except 504/524), and no retry for 2xx.
+var AutomaticRetryStatusCodeRanges = []StatusCodeRange{
+	{Start: 100, End: 199},
+	{Start: 300, End: 399},
+	{Start: 401, End: 407},
+	{Start: 409, End: 499},
+	{Start: 500, End: 503},
+	{Start: 505, End: 523},
+	{Start: 525, End: 599},
+}
+
+func AutomaticDisableStatusCodesToString() string {
+	return statusCodeRangesToString(AutomaticDisableStatusCodeRanges)
+}
+
+func AutomaticDisableStatusCodesFromString(s string) error {
+	ranges, err := ParseHTTPStatusCodeRanges(s)
+	if err != nil {
+		return err
+	}
+	AutomaticDisableStatusCodeRanges = ranges
+	return nil
+}
+
+func ShouldDisableByStatusCode(code int) bool {
+	return shouldMatchStatusCodeRanges(AutomaticDisableStatusCodeRanges, code)
+}
+
+func AutomaticRetryStatusCodesToString() string {
+	return statusCodeRangesToString(AutomaticRetryStatusCodeRanges)
+}
+
+func AutomaticRetryStatusCodesFromString(s string) error {
+	ranges, err := ParseHTTPStatusCodeRanges(s)
+	if err != nil {
+		return err
+	}
+	AutomaticRetryStatusCodeRanges = ranges
+	return nil
+}
+
+func ShouldRetryByStatusCode(code int) bool {
+	return shouldMatchStatusCodeRanges(AutomaticRetryStatusCodeRanges, code)
+}
+
+func statusCodeRangesToString(ranges []StatusCodeRange) string {
+	if len(ranges) == 0 {
+		return ""
+	}
+	parts := make([]string, 0, len(ranges))
+	for _, r := range ranges {
+		if r.Start == r.End {
+			parts = append(parts, strconv.Itoa(r.Start))
+			continue
+		}
+		parts = append(parts, fmt.Sprintf("%d-%d", r.Start, r.End))
+	}
+	return strings.Join(parts, ",")
+}
+
+func shouldMatchStatusCodeRanges(ranges []StatusCodeRange, code int) bool {
+	if code < 100 || code > 599 {
+		return false
+	}
+	for _, r := range ranges {
+		if code < r.Start {
+			return false
+		}
+		if code <= r.End {
+			return true
+		}
+	}
+	return false
+}
+
+func ParseHTTPStatusCodeRanges(input string) ([]StatusCodeRange, error) {
+	input = strings.TrimSpace(input)
+	if input == "" {
+		return nil, nil
+	}
+
+	input = strings.NewReplacer(",", ",").Replace(input)
+	segments := strings.Split(input, ",")
+
+	var ranges []StatusCodeRange
+	var invalid []string
+
+	for _, seg := range segments {
+		seg = strings.TrimSpace(seg)
+		if seg == "" {
+			continue
+		}
+		r, err := parseHTTPStatusCodeToken(seg)
+		if err != nil {
+			invalid = append(invalid, seg)
+			continue
+		}
+		ranges = append(ranges, r)
+	}
+
+	if len(invalid) > 0 {
+		return nil, fmt.Errorf("invalid http status code rules: %s", strings.Join(invalid, ", "))
+	}
+	if len(ranges) == 0 {
+		return nil, nil
+	}
+
+	sort.Slice(ranges, func(i, j int) bool {
+		if ranges[i].Start == ranges[j].Start {
+			return ranges[i].End < ranges[j].End
+		}
+		return ranges[i].Start < ranges[j].Start
+	})
+
+	merged := []StatusCodeRange{ranges[0]}
+	for _, r := range ranges[1:] {
+		last := &merged[len(merged)-1]
+		if r.Start <= last.End+1 {
+			if r.End > last.End {
+				last.End = r.End
+			}
+			continue
+		}
+		merged = append(merged, r)
+	}
+
+	return merged, nil
+}
+
+func parseHTTPStatusCodeToken(token string) (StatusCodeRange, error) {
+	token = strings.TrimSpace(token)
+	token = strings.ReplaceAll(token, " ", "")
+	if token == "" {
+		return StatusCodeRange{}, fmt.Errorf("empty token")
+	}
+
+	if strings.Contains(token, "-") {
+		parts := strings.Split(token, "-")
+		if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+			return StatusCodeRange{}, fmt.Errorf("invalid range token: %s", token)
+		}
+		start, err := strconv.Atoi(parts[0])
+		if err != nil {
+			return StatusCodeRange{}, fmt.Errorf("invalid range start: %s", token)
+		}
+		end, err := strconv.Atoi(parts[1])
+		if err != nil {
+			return StatusCodeRange{}, fmt.Errorf("invalid range end: %s", token)
+		}
+		if start > end {
+			return StatusCodeRange{}, fmt.Errorf("range start > end: %s", token)
+		}
+		if start < 100 || end > 599 {
+			return StatusCodeRange{}, fmt.Errorf("range out of bounds: %s", token)
+		}
+		return StatusCodeRange{Start: start, End: end}, nil
+	}
+
+	code, err := strconv.Atoi(token)
+	if err != nil {
+		return StatusCodeRange{}, fmt.Errorf("invalid status code: %s", token)
+	}
+	if code < 100 || code > 599 {
+		return StatusCodeRange{}, fmt.Errorf("status code out of bounds: %s", token)
+	}
+	return StatusCodeRange{Start: code, End: code}, nil
+}

+ 79 - 0
setting/operation_setting/status_code_ranges_test.go

@@ -0,0 +1,79 @@
+package operation_setting
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseHTTPStatusCodeRanges_CommaSeparated(t *testing.T) {
+	ranges, err := ParseHTTPStatusCodeRanges("401,403,500-599")
+	require.NoError(t, err)
+	require.Equal(t, []StatusCodeRange{
+		{Start: 401, End: 401},
+		{Start: 403, End: 403},
+		{Start: 500, End: 599},
+	}, ranges)
+}
+
+func TestParseHTTPStatusCodeRanges_MergeAndNormalize(t *testing.T) {
+	ranges, err := ParseHTTPStatusCodeRanges("500-505,504,401,403,402")
+	require.NoError(t, err)
+	require.Equal(t, []StatusCodeRange{
+		{Start: 401, End: 403},
+		{Start: 500, End: 505},
+	}, ranges)
+}
+
+func TestParseHTTPStatusCodeRanges_Invalid(t *testing.T) {
+	_, err := ParseHTTPStatusCodeRanges("99,600,foo,500-400,500-")
+	require.Error(t, err)
+}
+
+func TestParseHTTPStatusCodeRanges_NoComma_IsInvalid(t *testing.T) {
+	_, err := ParseHTTPStatusCodeRanges("401 403")
+	require.Error(t, err)
+}
+
+func TestShouldDisableByStatusCode(t *testing.T) {
+	orig := AutomaticDisableStatusCodeRanges
+	t.Cleanup(func() { AutomaticDisableStatusCodeRanges = orig })
+
+	AutomaticDisableStatusCodeRanges = []StatusCodeRange{
+		{Start: 401, End: 403},
+		{Start: 500, End: 599},
+	}
+
+	require.True(t, ShouldDisableByStatusCode(401))
+	require.True(t, ShouldDisableByStatusCode(403))
+	require.False(t, ShouldDisableByStatusCode(404))
+	require.True(t, ShouldDisableByStatusCode(500))
+	require.False(t, ShouldDisableByStatusCode(200))
+}
+
+func TestShouldRetryByStatusCode(t *testing.T) {
+	orig := AutomaticRetryStatusCodeRanges
+	t.Cleanup(func() { AutomaticRetryStatusCodeRanges = orig })
+
+	AutomaticRetryStatusCodeRanges = []StatusCodeRange{
+		{Start: 429, End: 429},
+		{Start: 500, End: 599},
+	}
+
+	require.True(t, ShouldRetryByStatusCode(429))
+	require.True(t, ShouldRetryByStatusCode(500))
+	require.False(t, ShouldRetryByStatusCode(400))
+	require.False(t, ShouldRetryByStatusCode(200))
+}
+
+func TestShouldRetryByStatusCode_DefaultMatchesLegacyBehavior(t *testing.T) {
+	require.False(t, ShouldRetryByStatusCode(200))
+	require.False(t, ShouldRetryByStatusCode(400))
+	require.True(t, ShouldRetryByStatusCode(401))
+	require.False(t, ShouldRetryByStatusCode(408))
+	require.True(t, ShouldRetryByStatusCode(429))
+	require.True(t, ShouldRetryByStatusCode(500))
+	require.False(t, ShouldRetryByStatusCode(504))
+	require.False(t, ShouldRetryByStatusCode(524))
+	require.True(t, ShouldRetryByStatusCode(599))
+}

+ 28 - 0
types/error.go

@@ -130,6 +130,20 @@ func (e *NewAPIError) Error() string {
 	return e.Err.Error()
 }
 
+func (e *NewAPIError) ErrorWithStatusCode() string {
+	if e == nil {
+		return ""
+	}
+	msg := e.Error()
+	if e.StatusCode == 0 {
+		return msg
+	}
+	if msg == "" {
+		return fmt.Sprintf("status_code=%d", e.StatusCode)
+	}
+	return fmt.Sprintf("status_code=%d, %s", e.StatusCode, msg)
+}
+
 func (e *NewAPIError) MaskSensitiveError() string {
 	if e == nil {
 		return ""
@@ -144,6 +158,20 @@ func (e *NewAPIError) MaskSensitiveError() string {
 	return common.MaskSensitiveInfo(errStr)
 }
 
+func (e *NewAPIError) MaskSensitiveErrorWithStatusCode() string {
+	if e == nil {
+		return ""
+	}
+	msg := e.MaskSensitiveError()
+	if e.StatusCode == 0 {
+		return msg
+	}
+	if msg == "" {
+		return fmt.Sprintf("status_code=%d", e.StatusCode)
+	}
+	return fmt.Sprintf("status_code=%d, %s", e.StatusCode, msg)
+}
+
 func (e *NewAPIError) SetMessage(message string) {
 	e.Err = errors.New(message)
 }

+ 15 - 7
web/src/components/auth/LoginForm.jsx

@@ -17,9 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React, { useContext, useEffect, useRef, useState } from 'react';
+import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
 import { Link, useNavigate, useSearchParams } from 'react-router-dom';
 import { UserContext } from '../../context/User';
+import { StatusContext } from '../../context/Status';
 import {
   API,
   getLogo,
@@ -73,6 +74,7 @@ const LoginForm = () => {
   const [searchParams, setSearchParams] = useSearchParams();
   const [submitted, setSubmitted] = useState(false);
   const [userState, userDispatch] = useContext(UserContext);
+  const [statusState] = useContext(StatusContext);
   const [turnstileEnabled, setTurnstileEnabled] = useState(false);
   const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
   const [turnstileToken, setTurnstileToken] = useState('');
@@ -108,20 +110,26 @@ const LoginForm = () => {
     localStorage.setItem('aff', affCode);
   }
 
-  const [status] = useState(() => {
+  const status = useMemo(() => {
+    if (statusState?.status) return statusState.status;
     const savedStatus = localStorage.getItem('status');
-    return savedStatus ? JSON.parse(savedStatus) : {};
-  });
+    if (!savedStatus) return {};
+    try {
+      return JSON.parse(savedStatus) || {};
+    } catch (err) {
+      return {};
+    }
+  }, [statusState?.status]);
 
   useEffect(() => {
-    if (status.turnstile_check) {
+    if (status?.turnstile_check) {
       setTurnstileEnabled(true);
       setTurnstileSiteKey(status.turnstile_site_key);
     }
     
     // 从 status 获取用户协议和隐私政策的启用状态
-    setHasUserAgreement(status.user_agreement_enabled || false);
-    setHasPrivacyPolicy(status.privacy_policy_enabled || false);
+    setHasUserAgreement(status?.user_agreement_enabled || false);
+    setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
   }, [status]);
 
   useEffect(() => {

+ 40 - 16
web/src/components/auth/RegisterForm.jsx

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React, { useContext, useEffect, useRef, useState } from 'react';
+import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
 import { Link, useNavigate } from 'react-router-dom';
 import {
   API,
@@ -31,7 +31,15 @@ import {
   onDiscordOAuthClicked,
 } from '../../helpers';
 import Turnstile from 'react-turnstile';
-import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Card,
+  Checkbox,
+  Divider,
+  Form,
+  Icon,
+  Modal,
+} from '@douyinfe/semi-ui';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import {
@@ -51,6 +59,7 @@ import LinuxDoIcon from '../common/logo/LinuxDoIcon';
 import WeChatIcon from '../common/logo/WeChatIcon';
 import TelegramLoginButton from 'react-telegram-login/src';
 import { UserContext } from '../../context/User';
+import { StatusContext } from '../../context/Status';
 import { useTranslation } from 'react-i18next';
 import { SiDiscord } from 'react-icons/si';
 
@@ -72,6 +81,7 @@ const RegisterForm = () => {
   });
   const { username, password, password2 } = inputs;
   const [userState, userDispatch] = useContext(UserContext);
+  const [statusState] = useContext(StatusContext);
   const [turnstileEnabled, setTurnstileEnabled] = useState(false);
   const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
   const [turnstileToken, setTurnstileToken] = useState('');
@@ -106,25 +116,29 @@ const RegisterForm = () => {
     localStorage.setItem('aff', affCode);
   }
 
-  const [status] = useState(() => {
+  const status = useMemo(() => {
+    if (statusState?.status) return statusState.status;
     const savedStatus = localStorage.getItem('status');
-    return savedStatus ? JSON.parse(savedStatus) : {};
-  });
+    if (!savedStatus) return {};
+    try {
+      return JSON.parse(savedStatus) || {};
+    } catch (err) {
+      return {};
+    }
+  }, [statusState?.status]);
 
-  const [showEmailVerification, setShowEmailVerification] = useState(() => {
-    return status.email_verification ?? false;
-  });
+  const [showEmailVerification, setShowEmailVerification] = useState(false);
 
   useEffect(() => {
-    setShowEmailVerification(status.email_verification);
-    if (status.turnstile_check) {
+    setShowEmailVerification(!!status?.email_verification);
+    if (status?.turnstile_check) {
       setTurnstileEnabled(true);
       setTurnstileSiteKey(status.turnstile_site_key);
     }
-    
+
     // 从 status 获取用户协议和隐私政策的启用状态
-    setHasUserAgreement(status.user_agreement_enabled || false);
-    setHasPrivacyPolicy(status.privacy_policy_enabled || false);
+    setHasUserAgreement(status?.user_agreement_enabled || false);
+    setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
   }, [status]);
 
   useEffect(() => {
@@ -235,7 +249,7 @@ const RegisterForm = () => {
     setVerificationCodeLoading(true);
     try {
       const res = await API.get(
-        `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
+        `/api/verification?email=${encodeURIComponent(inputs.email)}&turnstile=${turnstileToken}`,
       );
       const { success, message } = res.data;
       if (success) {
@@ -405,7 +419,15 @@ const RegisterForm = () => {
                     theme='outline'
                     className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
                     type='tertiary'
-                    icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
+                    icon={
+                      <SiDiscord
+                        style={{
+                          color: '#5865F2',
+                          width: '20px',
+                          height: '20px',
+                        }}
+                      />
+                    }
                     onClick={handleDiscordClick}
                     loading={discordLoading}
                   >
@@ -619,7 +641,9 @@ const RegisterForm = () => {
                     htmlType='submit'
                     onClick={handleSubmit}
                     loading={registerLoading}
-                    disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
+                    disabled={
+                      (hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
+                    }
                   >
                     {t('注册')}
                   </Button>

+ 71 - 0
web/src/components/settings/HttpStatusCodeRulesInput.jsx

@@ -0,0 +1,71 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Form, Tag, Typography } from '@douyinfe/semi-ui';
+
+export default function HttpStatusCodeRulesInput(props) {
+  const { Text } = Typography;
+  const {
+    label,
+    field,
+    placeholder,
+    extraText,
+    onChange,
+    parsed,
+    invalidText,
+  } = props;
+
+  return (
+    <>
+      <Form.Input
+        label={label}
+        placeholder={placeholder}
+        extraText={extraText}
+        field={field}
+        onChange={onChange}
+      />
+      {parsed?.ok && parsed.tokens?.length > 0 && (
+        <div
+          style={{
+            display: 'flex',
+            flexWrap: 'wrap',
+            gap: 8,
+            marginTop: 8,
+          }}
+        >
+          {parsed.tokens.map((token) => (
+            <Tag key={token} size='small'>
+              {token}
+            </Tag>
+          ))}
+        </div>
+      )}
+      {!parsed?.ok && (
+        <Text type='danger' style={{ display: 'block', marginTop: 8 }}>
+          {invalidText}
+          {parsed?.invalidTokens && parsed.invalidTokens.length > 0
+            ? `: ${parsed.invalidTokens.join(', ')}`
+            : ''}
+        </Text>
+      )}
+    </>
+  );
+}
+

+ 9 - 2
web/src/components/settings/ModelSetting.jsx

@@ -39,6 +39,7 @@ const ModelSetting = () => {
     'claude.thinking_adapter_budget_tokens_percentage': 0.8,
     'global.pass_through_request_enabled': false,
     'global.thinking_model_blacklist': '[]',
+    'global.chat_completions_to_responses_policy': '{}',
     'general_setting.ping_interval_enabled': false,
     'general_setting.ping_interval_seconds': 60,
     'gemini.thinking_adapter_enabled': false,
@@ -59,10 +60,16 @@ const ModelSetting = () => {
           item.key === 'claude.model_headers_settings' ||
           item.key === 'claude.default_max_tokens' ||
           item.key === 'gemini.supported_imagine_models' ||
-          item.key === 'global.thinking_model_blacklist'
+          item.key === 'global.thinking_model_blacklist' ||
+          item.key === 'global.chat_completions_to_responses_policy'
         ) {
           if (item.value !== '') {
-            item.value = JSON.stringify(JSON.parse(item.value), null, 2);
+            try {
+              item.value = JSON.stringify(JSON.parse(item.value), null, 2);
+            } catch (e) {
+              // Keep raw value so user can fix it, and avoid crashing the page.
+              console.error(`Invalid JSON for option ${item.key}:`, e);
+            }
           }
         }
         // Keep boolean config keys ending with enabled/Enabled so UI parses correctly.

+ 2 - 0
web/src/components/settings/OperationSetting.jsx

@@ -70,6 +70,8 @@ const OperationSetting = () => {
     AutomaticDisableChannelEnabled: false,
     AutomaticEnableChannelEnabled: false,
     AutomaticDisableKeywords: '',
+    AutomaticDisableStatusCodes: '401',
+    AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
     'monitor_setting.auto_test_channel_enabled': false,
     'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
     'checkin_setting.enabled': false,

+ 1 - 1
web/src/components/settings/personal/cards/NotificationSettings.jsx

@@ -440,7 +440,7 @@ const NotificationSettings = ({
                   data={[
                     { value: 100000, label: '0.2$' },
                     { value: 500000, label: '1$' },
-                    { value: 1000000, label: '5$' },
+                    { value: 1000000, label: '2$' },
                     { value: 5000000, label: '10$' },
                   ]}
                   onChange={(val) => handleFormChange('warningThreshold', val)}

+ 151 - 0
web/src/components/table/channels/modals/CodexOAuthModal.jsx

@@ -0,0 +1,151 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Modal, Button, Space, Typography, Input, Banner } from '@douyinfe/semi-ui';
+import { API, copy, showError, showSuccess } from '../../../../helpers';
+
+const { Text } = Typography;
+
+const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [authorizeUrl, setAuthorizeUrl] = useState('');
+  const [input, setInput] = useState('');
+
+  const startOAuth = async () => {
+    setLoading(true);
+    try {
+      const res = await API.post('/api/channel/codex/oauth/start', {}, { skipErrorHandler: true });
+      if (!res?.data?.success) {
+        console.error('Codex OAuth start failed:', res?.data?.message);
+        throw new Error(t('启动授权失败'));
+      }
+      const url = res?.data?.data?.authorize_url || '';
+      if (!url) {
+        console.error('Codex OAuth start response missing authorize_url:', res?.data);
+        throw new Error(t('响应缺少授权链接'));
+      }
+      setAuthorizeUrl(url);
+      window.open(url, '_blank', 'noopener,noreferrer');
+      showSuccess(t('已打开授权页面'));
+    } catch (error) {
+      showError(error?.message || t('启动授权失败'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const completeOAuth = async () => {
+    if (!input || !input.trim()) {
+      showError(t('请先粘贴回调 URL'));
+      return;
+    }
+
+    setLoading(true);
+    try {
+      const res = await API.post(
+        '/api/channel/codex/oauth/complete',
+        { input },
+        { skipErrorHandler: true },
+      );
+      if (!res?.data?.success) {
+        console.error('Codex OAuth complete failed:', res?.data?.message);
+        throw new Error(t('授权失败'));
+      }
+
+      const key = res?.data?.data?.key || '';
+      if (!key) {
+        console.error('Codex OAuth complete response missing key:', res?.data);
+        throw new Error(t('响应缺少凭据'));
+      }
+
+      onSuccess && onSuccess(key);
+      showSuccess(t('已生成授权凭据'));
+      onCancel && onCancel();
+    } catch (error) {
+      showError(error?.message || t('授权失败'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (!visible) return;
+    setAuthorizeUrl('');
+    setInput('');
+  }, [visible]);
+
+  return (
+    <Modal
+      title={t('Codex 授权')}
+      visible={visible}
+      onCancel={onCancel}
+      maskClosable={false}
+      closeOnEsc
+      width={720}
+      footer={
+        <Space>
+          <Button theme='borderless' onClick={onCancel} disabled={loading}>
+            {t('取消')}
+          </Button>
+          <Button theme='solid' type='primary' onClick={completeOAuth} loading={loading}>
+            {t('生成并填入')}
+          </Button>
+        </Space>
+      }
+    >
+      <Space vertical spacing='tight' style={{ width: '100%' }}>
+        <Banner
+          type='info'
+          description={t(
+            '1) 点击「打开授权页面」完成登录;2) 浏览器会跳转到 localhost(页面打不开也没关系);3) 复制地址栏完整 URL 粘贴到下方;4) 点击「生成并填入」。',
+          )}
+        />
+
+        <Space wrap>
+          <Button type='primary' onClick={startOAuth} loading={loading}>
+            {t('打开授权页面')}
+          </Button>
+          <Button
+            theme='outline'
+            disabled={!authorizeUrl || loading}
+            onClick={() => copy(authorizeUrl)}
+          >
+            {t('复制授权链接')}
+          </Button>
+        </Space>
+
+        <Input
+          value={input}
+          onChange={(value) => setInput(value)}
+          placeholder={t('请粘贴完整回调 URL(包含 code 与 state)')}
+          showClear
+        />
+
+        <Text type='tertiary' size='small'>
+          {t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')}
+        </Text>
+      </Space>
+    </Modal>
+  );
+};
+
+export default CodexOAuthModal;

+ 190 - 0
web/src/components/table/channels/modals/CodexUsageModal.jsx

@@ -0,0 +1,190 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Modal, Button, Progress, Tag, Typography } from '@douyinfe/semi-ui';
+
+const { Text } = Typography;
+
+const clampPercent = (value) => {
+  const v = Number(value);
+  if (!Number.isFinite(v)) return 0;
+  return Math.max(0, Math.min(100, v));
+};
+
+const pickStrokeColor = (percent) => {
+  const p = clampPercent(percent);
+  if (p >= 95) return '#ef4444';
+  if (p >= 80) return '#f59e0b';
+  return '#3b82f6';
+};
+
+const formatDurationSeconds = (seconds, t) => {
+  const tt = typeof t === 'function' ? t : (v) => v;
+  const s = Number(seconds);
+  if (!Number.isFinite(s) || s <= 0) return '-';
+  const total = Math.floor(s);
+  const hours = Math.floor(total / 3600);
+  const minutes = Math.floor((total % 3600) / 60);
+  const secs = total % 60;
+  if (hours > 0) return `${hours}${tt('小时')} ${minutes}${tt('分钟')}`;
+  if (minutes > 0) return `${minutes}${tt('分钟')} ${secs}${tt('秒')}`;
+  return `${secs}${tt('秒')}`;
+};
+
+const formatUnixSeconds = (unixSeconds) => {
+  const v = Number(unixSeconds);
+  if (!Number.isFinite(v) || v <= 0) return '-';
+  try {
+    return new Date(v * 1000).toLocaleString();
+  } catch (error) {
+    return String(unixSeconds);
+  }
+};
+
+const RateLimitWindowCard = ({ t, title, windowData }) => {
+  const tt = typeof t === 'function' ? t : (v) => v;
+  const percent = clampPercent(windowData?.used_percent ?? 0);
+  const resetAt = windowData?.reset_at;
+  const resetAfterSeconds = windowData?.reset_after_seconds;
+  const limitWindowSeconds = windowData?.limit_window_seconds;
+
+  return (
+    <div className='rounded-lg border border-semi-color-border bg-semi-color-bg-0 p-3'>
+      <div className='flex items-center justify-between gap-2'>
+        <div className='font-medium'>{title}</div>
+        <Text type='tertiary' size='small'>
+          {tt('重置时间:')}
+          {formatUnixSeconds(resetAt)}
+        </Text>
+      </div>
+
+      <div className='mt-2'>
+        <Progress
+          percent={percent}
+          stroke={pickStrokeColor(percent)}
+          showInfo={true}
+        />
+      </div>
+
+      <div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
+        <div>
+          {tt('已使用:')}
+          {percent}%
+        </div>
+        <div>
+          {tt('距离重置:')}
+          {formatDurationSeconds(resetAfterSeconds, tt)}
+        </div>
+        <div>
+          {tt('窗口:')}
+          {formatDurationSeconds(limitWindowSeconds, tt)}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
+  const tt = typeof t === 'function' ? t : (v) => v;
+  const data = payload?.data ?? null;
+  const rateLimit = data?.rate_limit ?? {};
+
+  const primary = rateLimit?.primary_window ?? null;
+  const secondary = rateLimit?.secondary_window ?? null;
+
+  const allowed = !!rateLimit?.allowed;
+  const limitReached = !!rateLimit?.limit_reached;
+  const upstreamStatus = payload?.upstream_status;
+
+  const statusTag =
+    allowed && !limitReached ? (
+      <Tag color='green'>{tt('可用')}</Tag>
+    ) : (
+      <Tag color='red'>{tt('受限')}</Tag>
+    );
+
+  const rawText =
+    typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
+
+  Modal.info({
+    title: (
+      <div className='flex items-center gap-2'>
+        <span>{tt('Codex 用量')}</span>
+        {statusTag}
+      </div>
+    ),
+    centered: true,
+    width: 900,
+    style: { maxWidth: '95vw' },
+    content: (
+      <div className='flex flex-col gap-3'>
+        <div className='flex flex-wrap items-center justify-between gap-2'>
+          <Text type='tertiary' size='small'>
+            {tt('渠道:')}
+            {record?.name || '-'} ({tt('编号:')}
+            {record?.id || '-'})
+          </Text>
+          <Text type='tertiary' size='small'>
+            {tt('上游状态码:')}
+            {upstreamStatus ?? '-'}
+          </Text>
+        </div>
+
+        <div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
+          <RateLimitWindowCard
+            t={tt}
+            title={tt('5小时窗口')}
+            windowData={primary}
+          />
+          <RateLimitWindowCard
+            t={tt}
+            title={tt('每周窗口')}
+            windowData={secondary}
+          />
+        </div>
+
+        <div>
+          <div className='mb-1 flex items-center justify-between gap-2'>
+            <div className='text-sm font-medium'>{tt('原始 JSON')}</div>
+            <Button
+              size='small'
+              type='primary'
+              theme='outline'
+              onClick={() => onCopy?.(rawText)}
+              disabled={!rawText}
+            >
+              {tt('复制')}
+            </Button>
+          </div>
+          <pre className='max-h-[50vh] overflow-auto rounded-lg bg-semi-color-fill-0 p-3 text-xs text-semi-color-text-0'>
+            {rawText}
+          </pre>
+        </div>
+      </div>
+    ),
+    footer: (
+      <div className='flex justify-end gap-2'>
+        <Button type='primary' theme='solid' onClick={() => Modal.destroyAll()}>
+          {tt('关闭')}
+        </Button>
+      </div>
+    ),
+  });
+};

+ 263 - 70
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -58,6 +58,7 @@ import {
 import ModelSelectModal from './ModelSelectModal';
 import SingleModelSelectModal from './SingleModelSelectModal';
 import OllamaModelModal from './OllamaModelModal';
+import CodexOAuthModal from './CodexOAuthModal';
 import JSONEditor from '../../../common/ui/JSONEditor';
 import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
 import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
@@ -95,7 +96,7 @@ const REGION_EXAMPLE = {
 
 // 支持并且已适配通过接口获取模型列表的渠道类型
 const MODEL_FETCHABLE_TYPES = new Set([
-  1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
+  1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
 ]);
 
 function type2secretPrompt(type) {
@@ -117,6 +118,8 @@ function type2secretPrompt(type) {
       return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey';
     case 51:
       return '按照如下格式输入: AccessKey|SecretAccessKey';
+    case 57:
+      return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id)';
     default:
       return '请输入渠道对应的鉴权密钥';
   }
@@ -222,6 +225,9 @@ const EditChannelModal = (props) => {
   }, [inputs.model_mapping]);
   const [isIonetChannel, setIsIonetChannel] = useState(false);
   const [ionetMetadata, setIonetMetadata] = useState(null);
+  const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
+  const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
+    useState(false);
 
   // 密钥显示状态
   const [keyDisplayState, setKeyDisplayState] = useState({
@@ -513,10 +519,34 @@ const EditChannelModal = (props) => {
 
       // 重置手动输入模式状态
       setUseManualInput(false);
+
+      if (value === 57) {
+        setBatch(false);
+        setMultiToSingle(false);
+        setMultiKeyMode('random');
+        setVertexKeys([]);
+        setVertexFileList([]);
+        if (formApiRef.current) {
+          formApiRef.current.setValue('vertex_files', []);
+        }
+        setInputs((prev) => ({ ...prev, vertex_files: [] }));
+      }
     }
     //setAutoBan
   };
 
+  const formatJsonField = (fieldName) => {
+    const rawValue = (inputs?.[fieldName] ?? '').trim();
+    if (!rawValue) return;
+
+    try {
+      const parsed = JSON.parse(rawValue);
+      handleInputChange(fieldName, JSON.stringify(parsed, null, 2));
+    } catch (error) {
+      showError(`${t('JSON格式错误')}: ${error.message}`);
+    }
+  };
+
   const loadChannel = async () => {
     setLoading(true);
     let res = await API.get(`/api/channel/${channelId}`);
@@ -863,6 +893,32 @@ const EditChannelModal = (props) => {
     }
   };
 
+  const handleCodexOAuthGenerated = (key) => {
+    handleInputChange('key', key);
+    formatJsonField('key');
+  };
+
+  const handleRefreshCodexCredential = async () => {
+    if (!isEdit) return;
+
+    setCodexCredentialRefreshing(true);
+    try {
+      const res = await API.post(
+        `/api/channel/${channelId}/codex/refresh`,
+        {},
+        { skipErrorHandler: true },
+      );
+      if (!res?.data?.success) {
+        throw new Error(res?.data?.message || 'Failed to refresh credential');
+      }
+      showSuccess(t('凭证已刷新'));
+    } catch (error) {
+      showError(error.message || t('刷新失败'));
+    } finally {
+      setCodexCredentialRefreshing(false);
+    }
+  };
+
   useEffect(() => {
     if (inputs.type !== 45) {
       doubaoApiClickCountRef.current = 0;
@@ -1111,6 +1167,47 @@ const EditChannelModal = (props) => {
     const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
     let localInputs = { ...formValues };
 
+    if (localInputs.type === 57) {
+      if (batch) {
+        showInfo(t('Codex 渠道不支持批量创建'));
+        return;
+      }
+
+      const rawKey = (localInputs.key || '').trim();
+      if (!isEdit && rawKey === '') {
+        showInfo(t('请输入密钥!'));
+        return;
+      }
+
+      if (rawKey !== '') {
+        if (!verifyJSON(rawKey)) {
+          showInfo(t('密钥必须是合法的 JSON 格式!'));
+          return;
+        }
+        try {
+          const parsed = JSON.parse(rawKey);
+          if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+            showInfo(t('密钥必须是 JSON 对象'));
+            return;
+          }
+          const accessToken = String(parsed.access_token || '').trim();
+          const accountId = String(parsed.account_id || '').trim();
+          if (!accessToken) {
+            showInfo(t('密钥 JSON 必须包含 access_token'));
+            return;
+          }
+          if (!accountId) {
+            showInfo(t('密钥 JSON 必须包含 account_id'));
+            return;
+          }
+          localInputs.key = JSON.stringify(parsed);
+        } catch (error) {
+          showInfo(t('密钥必须是合法的 JSON 格式!'));
+          return;
+        }
+      }
+    }
+
     if (localInputs.type === 41) {
       const keyType = localInputs.vertex_key_type || 'json';
       if (keyType === 'api_key') {
@@ -1442,7 +1539,7 @@ const EditChannelModal = (props) => {
     }
   };
 
-  const batchAllowed = !isEdit || isMultiKeyChannel;
+  const batchAllowed = (!isEdit || isMultiKeyChannel) && inputs.type !== 57;
   const batchExtra = batchAllowed ? (
     <Space>
       {!isEdit && (
@@ -1903,87 +2000,171 @@ const EditChannelModal = (props) => {
                           autoComplete='new-password'
                           onChange={(value) => handleInputChange('key', value)}
                           disabled={isIonetLocked}
-                          extraText={
-                            <div className='flex items-center gap-2 flex-wrap'>
-                              {isEdit &&
-                                isMultiKeyChannel &&
-                                keyMode === 'append' && (
-                                  <Text type='warning' size='small'>
-                                    {t(
-                                      '追加模式:新密钥将添加到现有密钥列表的末尾',
-                                    )}
-                                  </Text>
-                                )}
-                              {isEdit && (
-                                <Button
-                                  size='small'
-                                  type='primary'
-                                  theme='outline'
-                                  onClick={handleShow2FAModal}
-                                >
-                                  {t('查看密钥')}
-                                </Button>
+                        extraText={
+                          <div className='flex items-center gap-2 flex-wrap'>
+                            {isEdit &&
+                              isMultiKeyChannel &&
+                              keyMode === 'append' && (
+                                <Text type='warning' size='small'>
+                                  {t(
+                                    '追加模式:新密钥将添加到现有密钥列表的末尾',
+                                  )}
+                                </Text>
                               )}
-                              {batchExtra}
-                            </div>
-                          }
-                          showClear
-                        />
-                      )
-                    ) : (
-                      <>
-                        {inputs.type === 41 &&
-                        (inputs.vertex_key_type || 'json') === 'json' ? (
-                          <>
-                            {!batch && (
-                              <div className='flex items-center justify-between mb-3'>
-                                <Text className='text-sm font-medium'>
-                                  {t('密钥输入方式')}
+                            {isEdit && (
+                              <Button
+                                size='small'
+                                type='primary'
+                                theme='outline'
+                                onClick={handleShow2FAModal}
+                              >
+                                {t('查看密钥')}
+                              </Button>
+                            )}
+                            {batchExtra}
+                          </div>
+                        }
+                        showClear
+                      />
+                    )
+                  ) : (
+                    <>
+                      {inputs.type === 57 ? (
+                        <>
+                          <Form.TextArea
+                            field='key'
+                            label={
+                              isEdit
+                                ? t('密钥(编辑模式下,保存的密钥不会显示)')
+                                : t('密钥')
+                            }
+                            placeholder={t(
+                              '请输入 JSON 格式的 OAuth 凭据,例如:\n{\n  "access_token": "...",\n  "account_id": "..." \n}',
+                            )}
+                            rules={
+                              isEdit
+                                ? []
+                                : [{ required: true, message: t('请输入密钥') }]
+                            }
+                            autoComplete='new-password'
+                            onChange={(value) => handleInputChange('key', value)}
+                            disabled={isIonetLocked}
+                            extraText={
+                              <div className='flex flex-col gap-2'>
+                                <Text type='tertiary' size='small'>
+                                  {t(
+                                    '仅支持 JSON 对象,必须包含 access_token 与 account_id',
+                                  )}
                                 </Text>
-                                <Space>
+
+                                <Space wrap spacing='tight'>
                                   <Button
                                     size='small'
-                                    type={
-                                      !useManualInput ? 'primary' : 'tertiary'
+                                    type='primary'
+                                    theme='outline'
+                                    onClick={() =>
+                                      setCodexOAuthModalVisible(true)
                                     }
-                                    onClick={() => {
-                                      setUseManualInput(false);
-                                      // 切换到文件上传模式时清空手动输入的密钥
-                                      if (formApiRef.current) {
-                                        formApiRef.current.setValue('key', '');
-                                      }
-                                      handleInputChange('key', '');
-                                    }}
+                                    disabled={isIonetLocked}
                                   >
-                                    {t('文件上传')}
+                                    {t('Codex 授权')}
                                   </Button>
+                                  {isEdit && (
+                                    <Button
+                                      size='small'
+                                      type='primary'
+                                      theme='outline'
+                                      onClick={handleRefreshCodexCredential}
+                                      loading={codexCredentialRefreshing}
+                                      disabled={isIonetLocked}
+                                    >
+                                      {t('刷新凭证')}
+                                    </Button>
+                                  )}
                                   <Button
                                     size='small'
-                                    type={
-                                      useManualInput ? 'primary' : 'tertiary'
-                                    }
-                                    onClick={() => {
-                                      setUseManualInput(true);
-                                      // 切换到手动输入模式时清空文件上传相关状态
-                                      setVertexKeys([]);
-                                      setVertexFileList([]);
-                                      if (formApiRef.current) {
-                                        formApiRef.current.setValue(
-                                          'vertex_files',
-                                          [],
-                                        );
-                                      }
-                                      setInputs((prev) => ({
-                                        ...prev,
-                                        vertex_files: [],
-                                      }));
-                                    }}
+                                    type='primary'
+                                    theme='outline'
+                                    onClick={() => formatJsonField('key')}
+                                    disabled={isIonetLocked}
                                   >
-                                    {t('手动输入')}
+                                    {t('格式化')}
                                   </Button>
+                                  {isEdit && (
+                                    <Button
+                                      size='small'
+                                      type='primary'
+                                      theme='outline'
+                                      onClick={handleShow2FAModal}
+                                      disabled={isIonetLocked}
+                                    >
+                                      {t('查看密钥')}
+                                    </Button>
+                                  )}
+                                  {batchExtra}
                                 </Space>
                               </div>
-                            )}
+                            }
+                            autosize
+                            showClear
+                          />
+
+                          <CodexOAuthModal
+                            visible={codexOAuthModalVisible}
+                            onCancel={() => setCodexOAuthModalVisible(false)}
+                            onSuccess={handleCodexOAuthGenerated}
+                          />
+                        </>
+                      ) : inputs.type === 41 &&
+                        (inputs.vertex_key_type || 'json') === 'json' ? (
+                        <>
+                          {!batch && (
+                            <div className='flex items-center justify-between mb-3'>
+                              <Text className='text-sm font-medium'>
+                                {t('密钥输入方式')}
+                              </Text>
+                              <Space>
+                                <Button
+                                  size='small'
+                                  type={
+                                    !useManualInput ? 'primary' : 'tertiary'
+                                  }
+                                  onClick={() => {
+                                    setUseManualInput(false);
+                                    // 切换到文件上传模式时清空手动输入的密钥
+                                    if (formApiRef.current) {
+                                      formApiRef.current.setValue('key', '');
+                                    }
+                                    handleInputChange('key', '');
+                                  }}
+                                >
+                                  {t('文件上传')}
+                                </Button>
+                                <Button
+                                  size='small'
+                                  type={useManualInput ? 'primary' : 'tertiary'}
+                                  onClick={() => {
+                                    setUseManualInput(true);
+                                    // 切换到手动输入模式时清空文件上传相关状态
+                                    setVertexKeys([]);
+                                    setVertexFileList([]);
+                                    if (formApiRef.current) {
+                                      formApiRef.current.setValue(
+                                        'vertex_files',
+                                        [],
+                                      );
+                                    }
+                                    setInputs((prev) => ({
+                                      ...prev,
+                                      vertex_files: [],
+                                    }));
+                                  }}
+                                >
+                                  {t('手动输入')}
+                                </Button>
+                              </Space>
+                            </div>
+                          )}
 
                             {batch && (
                               <Banner
@@ -2896,6 +3077,12 @@ const EditChannelModal = (props) => {
                           >
                             {t('新格式模板')}
                           </Text>
+                          <Text
+                            className='!text-semi-color-primary cursor-pointer'
+                            onClick={() => formatJsonField('param_override')}
+                          >
+                            {t('格式化')}
+                          </Text>
                         </div>
                       }
                       showClear
@@ -2936,6 +3123,12 @@ const EditChannelModal = (props) => {
                             >
                               {t('填入模板')}
                             </Text>
+                            <Text
+                              className='!text-semi-color-primary cursor-pointer'
+                              onClick={() => formatJsonField('header_override')}
+                            >
+                              {t('格式化')}
+                            </Text>
                           </div>
                           <div>
                             <Text type='tertiary' size='small'>

+ 5 - 0
web/src/constants/channel.constants.js

@@ -184,6 +184,11 @@ export const CHANNEL_OPTIONS = [
     color: 'blue',
     label: 'Replicate',
   },
+  {
+    value: 57,
+    color: 'blue',
+    label: 'Codex (OpenAI OAuth)',
+  },
 ];
 
 export const MODEL_TABLE_PAGE_SIZE = 10;

+ 1 - 0
web/src/helpers/index.js

@@ -29,3 +29,4 @@ export * from './token';
 export * from './boolean';
 export * from './dashboard';
 export * from './passkey';
+export * from './statusCodeRules';

+ 1 - 0
web/src/helpers/render.jsx

@@ -301,6 +301,7 @@ export function getChannelIcon(channelType) {
   switch (channelType) {
     case 1: // OpenAI
     case 3: // Azure OpenAI
+    case 57: // Codex
       return <OpenAI size={iconSize} />;
     case 2: // Midjourney Proxy
     case 5: // Midjourney Proxy Plus

+ 96 - 0
web/src/helpers/statusCodeRules.js

@@ -0,0 +1,96 @@
+export function parseHttpStatusCodeRules(input) {
+  const raw = (input ?? '').toString().trim();
+  if (raw.length === 0) {
+    return {
+      ok: true,
+      ranges: [],
+      tokens: [],
+      normalized: '',
+      invalidTokens: [],
+    };
+  }
+
+  const sanitized = raw.replace(/[,]/g, ',');
+  const segments = sanitized.split(/[,]/g);
+
+  const ranges = [];
+  const invalidTokens = [];
+
+  for (const segment of segments) {
+    const trimmed = segment.trim();
+    if (!trimmed) continue;
+    const parsed = parseToken(trimmed);
+    if (!parsed) invalidTokens.push(trimmed);
+    else ranges.push(parsed);
+  }
+
+  if (invalidTokens.length > 0) {
+    return {
+      ok: false,
+      ranges: [],
+      tokens: [],
+      normalized: raw,
+      invalidTokens,
+    };
+  }
+
+  const merged = mergeRanges(ranges);
+  const tokens = merged.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`));
+  const normalized = tokens.join(',');
+
+  return {
+    ok: true,
+    ranges: merged,
+    tokens,
+    normalized,
+    invalidTokens: [],
+  };
+}
+
+function parseToken(token) {
+  const cleaned = (token ?? '').toString().trim().replaceAll(' ', '');
+  if (!cleaned) return null;
+
+  if (cleaned.includes('-')) {
+    const parts = cleaned.split('-');
+    if (parts.length !== 2) return null;
+    const [a, b] = parts;
+    if (!isNumber(a) || !isNumber(b)) return null;
+    const start = Number.parseInt(a, 10);
+    const end = Number.parseInt(b, 10);
+    if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
+    if (start > end) return null;
+    if (start < 100 || end > 599) return null;
+    return { start, end };
+  }
+
+  if (!isNumber(cleaned)) return null;
+  const code = Number.parseInt(cleaned, 10);
+  if (!Number.isFinite(code)) return null;
+  if (code < 100 || code > 599) return null;
+  return { start: code, end: code };
+}
+
+function isNumber(s) {
+  return typeof s === 'string' && /^\d+$/.test(s);
+}
+
+function mergeRanges(ranges) {
+  if (!Array.isArray(ranges) || ranges.length === 0) return [];
+
+  const sorted = [...ranges].sort((a, b) => (a.start !== b.start ? a.start - b.start : a.end - b.end));
+  const merged = [sorted[0]];
+
+  for (let i = 1; i < sorted.length; i += 1) {
+    const current = sorted[i];
+    const last = merged[merged.length - 1];
+
+    if (current.start <= last.end + 1) {
+      last.end = Math.max(last.end, current.end);
+      continue;
+    }
+    merged.push({ ...current });
+  }
+
+  return merged;
+}

+ 27 - 0
web/src/hooks/channels/useChannelsData.jsx

@@ -36,6 +36,7 @@ import {
 import { useIsMobile } from '../common/useIsMobile';
 import { useTableCompactMode } from '../common/useTableCompactMode';
 import { Modal, Button } from '@douyinfe/semi-ui';
+import { openCodexUsageModal } from '../../components/table/channels/modals/CodexUsageModal';
 
 export const useChannelsData = () => {
   const { t } = useTranslation();
@@ -745,6 +746,32 @@ export const useChannelsData = () => {
   };
 
   const updateChannelBalance = async (record) => {
+    if (record?.type === 57) {
+      try {
+        const res = await API.get(`/api/channel/${record.id}/codex/usage`, {
+          skipErrorHandler: true,
+        });
+        if (!res?.data?.success) {
+          console.error('Codex usage fetch failed:', res?.data?.message);
+          showError(t('获取用量失败'));
+        }
+        openCodexUsageModal({
+          t,
+          record,
+          payload: res?.data,
+          onCopy: async (text) => {
+            const ok = await copy(text);
+            if (ok) showSuccess(t('已复制'));
+            else showError(t('复制失败'));
+          },
+        });
+      } catch (error) {
+        console.error('Codex usage fetch error:', error);
+        showError(t('获取用量失败'));
+      }
+      return;
+    }
+
     const res = await API.get(`/api/channel/update_balance/${record.id}/`);
     const { success, message, balance } = res.data;
     if (success) {

+ 16 - 0
web/src/hooks/usage-logs/useUsageLogsData.jsx

@@ -306,6 +306,16 @@ export const useLogsData = () => {
 
   // Format logs data
   const setLogsFormat = (logs) => {
+    const requestConversionDisplayValue = (conversionChain) => {
+      const chain = Array.isArray(conversionChain)
+        ? conversionChain.filter(Boolean)
+        : [];
+      if (chain.length <= 1) {
+        return t('原生格式');
+      }
+      return `${chain.join(' -> ')}`;
+    };
+
     let expandDatesLocal = {};
     for (let i = 0; i < logs.length; i++) {
       logs[i].timestamp2string = timestamp2string(logs[i].created_at);
@@ -482,6 +492,12 @@ export const useLogsData = () => {
           value: other.request_path,
         });
       }
+      if (isAdminUser) {
+        expandDataLocal.push({
+          key: t('请求转换'),
+          value: requestConversionDisplayValue(other?.request_conversion),
+        });
+      }
       if (isAdminUser) {
         let localCountMode = '';
         if (other?.admin_info?.local_count_tokens) {

+ 14 - 0
web/src/i18n/locales/en.json

@@ -1923,6 +1923,12 @@
     "自动测试所有通道间隔时间": "Auto test interval for all channels",
     "自动禁用": "Auto disabled",
     "自动禁用关键词": "Automatic disable keywords",
+    "自动禁用状态码": "Auto-disable status codes",
+    "自动禁用状态码格式不正确": "Invalid auto-disable status code format",
+    "自动重试状态码": "Auto-retry status codes",
+    "自动重试状态码格式不正确": "Invalid auto-retry status code format",
+    "支持填写单个状态码或范围(含首尾),使用逗号分隔": "Supports single status codes or inclusive ranges; separate with commas",
+    "例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599",
     "自动选择": "Auto Select",
     "自定义充值数量选项": "Custom Recharge Amount Options",
     "自定义充值数量选项不是合法的 JSON 数组": "Custom recharge amount options is not a valid JSON array",
@@ -2087,6 +2093,9 @@
     "请求结束后多退少补": "Adjust after request completion",
     "请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login",
     "请求路径": "Request path",
+    "请求转换": "Request conversion",
+    "原生格式": "Native format",
+    "转换": "Convert",
     "请求预扣费额度": "Pre-deduction quota for requests",
     "请点击我": "Please click me",
     "请确认以下设置信息,点击\"初始化系统\"开始配置": "Please confirm the following settings information, click \"Initialize system\" to start configuration",
@@ -2583,6 +2592,11 @@
     "签到最大额度": "Maximum check-in quota",
     "签到奖励的最大额度": "Maximum quota for check-in rewards",
     "保存签到设置": "Save check-in settings",
+    "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses Compatibility (Beta)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Notice: This feature is beta. The configuration structure and behavior may change in the future. Do not use in production.",
+    "填充模板(指定渠道)": "Fill template (selected channels)",
+    "填充模板(全渠道)": "Fill template (all channels)",
+    "格式化 JSON": "Format JSON",
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Notice: This configuration only affects how models are displayed in the Model Marketplace and does not impact actual model invocation or routing. To configure real invocation behavior, please go to Channel Management.",
     "确认关闭提示": "Confirm close",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "After closing, this notice will no longer be shown (only for this browser). Are you sure you want to close it?",

+ 5 - 0
web/src/i18n/locales/fr.json

@@ -2597,6 +2597,11 @@
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Remarque : cette configuration n'affecte que l'affichage des modèles dans la place de marché des modèles et n'a aucun impact sur l'invocation ou le routage réels. Pour configurer le comportement réel des appels, veuillez aller dans « Gestion des canaux ».",
     "确认关闭提示": "Confirmer la fermeture",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "Après fermeture, cet avertissement ne sera plus affiché (uniquement pour ce navigateur). Voulez-vous vraiment le fermer ?",
+    "ChatCompletions→Responses 兼容配置(Beta)": "Compatibilité ChatCompletions→Responses (bêta)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Remarque : cette fonctionnalité est en version bêta. La structure de configuration et le comportement peuvent changer à l’avenir. Ne l’utilisez pas en production.",
+    "填充模板(指定渠道)": "Remplir le modèle (canaux sélectionnés)",
+    "填充模板(全渠道)": "Remplir le modèle (tous les canaux)",
+    "格式化 JSON": "Formater le JSON",
     "关闭提示": "Fermer l’avertissement",
     "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Remarque : les tests sur cette page utilisent des requêtes non-streaming. Si un canal ne prend en charge que les réponses en streaming, les tests peuvent échouer. Veuillez vous référer à l’usage réel.",
     "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Remarque : la correspondance des endpoints sert uniquement à l’affichage dans la place de marché des modèles et n’affecte pas l’invocation réelle. Pour configurer l’invocation réelle, veuillez aller dans « Gestion des canaux »."

+ 5 - 0
web/src/i18n/locales/ja.json

@@ -2580,6 +2580,11 @@
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "注意: ここでの設定は「モデル広場」での表示にのみ影響し、実際の呼び出しやルーティングには影響しません。実際の呼び出しを設定する場合は、「チャネル管理」で設定してください。",
     "确认关闭提示": "閉じる確認",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "閉じると、このお知らせは今後表示されません(このブラウザのみ)。閉じてもよろしいですか?",
+    "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 互換設定(ベータ)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "注意: この機能はベータ版です。今後、設定構造や挙動が変更される可能性があります。本番環境では使用しないでください。",
+    "填充模板(指定渠道)": "テンプレートを入力(指定チャネル)",
+    "填充模板(全渠道)": "テンプレートを入力(全チャネル)",
+    "格式化 JSON": "JSON を整形",
     "关闭提示": "お知らせを閉じる",
     "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "注意: このページのテストは非ストリーミングリクエストです。チャネルがストリーミング応答のみ対応の場合、テストが失敗することがあります。実際の利用結果を優先してください。",
     "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "注意: エンドポイントマッピングは「モデル広場」での表示専用で、実際の呼び出しには影響しません。実際の呼び出し設定は「チャネル管理」で行ってください。"

+ 5 - 0
web/src/i18n/locales/ru.json

@@ -2610,6 +2610,11 @@
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Примечание: эта настройка влияет только на отображение моделей в «Маркетплейсе моделей» и не влияет на фактический вызов или маршрутизацию. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».",
     "确认关闭提示": "Подтвердить закрытие",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "После закрытия это уведомление больше не будет показываться (только в этом браузере). Закрыть?",
+    "ChatCompletions→Responses 兼容配置(Beta)": "Совместимость ChatCompletions→Responses (бета)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Примечание: это бета-функция. Структура конфигурации и поведение могут измениться в будущем. Не используйте в продакшене.",
+    "填充模板(指定渠道)": "Заполнить шаблон (выбранные каналы)",
+    "填充模板(全渠道)": "Заполнить шаблон (все каналы)",
+    "格式化 JSON": "Форматировать JSON",
     "关闭提示": "Закрыть уведомление",
     "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Примечание: тесты на этой странице используют нестриминговые запросы. Если канал поддерживает только стриминговые ответы, тест может завершиться неудачей. Ориентируйтесь на реальное использование.",
     "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление endpoint'ов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами»."

+ 5 - 0
web/src/i18n/locales/vi.json

@@ -3160,6 +3160,11 @@
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Lưu ý: Cấu hình tại đây chỉ ảnh hưởng đến cách hiển thị trong \"Chợ mô hình\" và không ảnh hưởng đến việc gọi hoặc định tuyến thực tế. Nếu cần cấu hình hành vi gọi thực tế, vui lòng thiết lập trong \"Quản lý kênh\".",
     "确认关闭提示": "Xác nhận đóng",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "Sau khi đóng, thông báo này sẽ không còn hiển thị nữa (chỉ với trình duyệt này). Bạn có chắc muốn đóng không?",
+    "ChatCompletions→Responses 兼容配置(Beta)": "Tương thích ChatCompletions→Responses (Beta)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Lưu ý: Đây là tính năng beta. Cấu trúc cấu hình và hành vi có thể thay đổi trong tương lai. Không dùng trong môi trường production.",
+    "填充模板(指定渠道)": "Điền mẫu (kênh được chọn)",
+    "填充模板(全渠道)": "Điền mẫu (tất cả kênh)",
+    "格式化 JSON": "Định dạng JSON",
     "关闭提示": "Đóng thông báo",
     "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Lưu ý: Bài kiểm tra trên trang này sử dụng yêu cầu không streaming. Nếu kênh chỉ hỗ trợ phản hồi streaming, bài kiểm tra có thể thất bại. Vui lòng dựa vào sử dụng thực tế.",
     "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Lưu ý: Ánh xạ endpoint chỉ dùng để hiển thị trong \"Chợ mô hình\" và không ảnh hưởng đến việc gọi thực tế. Để cấu hình gọi thực tế, vui lòng vào \"Quản lý kênh\"."

+ 14 - 0
web/src/i18n/locales/zh.json

@@ -1909,6 +1909,12 @@
     "自动测试所有通道间隔时间": "自动测试所有通道间隔时间",
     "自动禁用": "自动禁用",
     "自动禁用关键词": "自动禁用关键词",
+    "自动禁用状态码": "自动禁用状态码",
+    "自动禁用状态码格式不正确": "自动禁用状态码格式不正确",
+    "自动重试状态码": "自动重试状态码",
+    "自动重试状态码格式不正确": "自动重试状态码格式不正确",
+    "支持填写单个状态码或范围(含首尾),使用逗号分隔": "支持填写单个状态码或范围(含首尾),使用逗号分隔",
+    "例如:401, 403, 429, 500-599": "例如:401,403,429,500-599",
     "自动选择": "自动选择",
     "自定义充值数量选项": "自定义充值数量选项",
     "自定义充值数量选项不是合法的 JSON 数组": "自定义充值数量选项不是合法的 JSON 数组",
@@ -2073,6 +2079,9 @@
     "请求结束后多退少补": "请求结束后多退少补",
     "请求超时,请刷新页面后重新发起 GitHub 登录": "请求超时,请刷新页面后重新发起 GitHub 登录",
     "请求路径": "请求路径",
+    "请求转换": "请求转换",
+    "原生格式": "原生格式",
+    "转换": "转换",
     "请求预扣费额度": "请求预扣费额度",
     "请点击我": "请点击我",
     "请确认以下设置信息,点击\"初始化系统\"开始配置": "请确认以下设置信息,点击\"初始化系统\"开始配置",
@@ -2569,6 +2578,11 @@
     "签到最大额度": "签到最大额度",
     "签到奖励的最大额度": "签到奖励的最大额度",
     "保存签到设置": "保存签到设置",
+    "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 兼容配置(Beta)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。",
+    "填充模板(指定渠道)": "填充模板(指定渠道)",
+    "填充模板(全渠道)": "填充模板(全渠道)",
+    "格式化 JSON": "格式化 JSON",
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。",
     "确认关闭提示": "确认关闭提示",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?",

+ 186 - 2
web/src/pages/Setting/Model/SettingGlobalModel.jsx

@@ -18,7 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
 */
 
 import React, { useEffect, useState, useRef } from 'react';
-import { Button, Col, Form, Row, Spin, Banner } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Col,
+  Form,
+  Row,
+  Spin,
+  Banner,
+  Tag,
+  Divider,
+} from '@douyinfe/semi-ui';
 import {
   compareObjects,
   API,
@@ -35,9 +44,31 @@ const thinkingExample = JSON.stringify(
   2,
 );
 
+const chatCompletionsToResponsesPolicyExample = JSON.stringify(
+  {
+    enabled: true,
+    all_channels: false,
+    channel_ids: [1, 2],
+    model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
+  },
+  null,
+  2,
+);
+
+const chatCompletionsToResponsesPolicyAllChannelsExample = JSON.stringify(
+  {
+    enabled: true,
+    all_channels: true,
+    model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
+  },
+  null,
+  2,
+);
+
 const defaultGlobalSettingInputs = {
   'global.pass_through_request_enabled': false,
   'global.thinking_model_blacklist': '[]',
+  'global.chat_completions_to_responses_policy': '{}',
   'general_setting.ping_interval_enabled': false,
   'general_setting.ping_interval_seconds': 60,
 };
@@ -49,12 +80,28 @@ export default function SettingGlobalModel(props) {
   const [inputs, setInputs] = useState(defaultGlobalSettingInputs);
   const refForm = useRef();
   const [inputsRow, setInputsRow] = useState(defaultGlobalSettingInputs);
+  const chatCompletionsToResponsesPolicyKey =
+    'global.chat_completions_to_responses_policy';
+
+  const setChatCompletionsToResponsesPolicyValue = (value) => {
+    setInputs((prev) => ({
+      ...prev,
+      [chatCompletionsToResponsesPolicyKey]: value,
+    }));
+    if (refForm.current) {
+      refForm.current.setValue(chatCompletionsToResponsesPolicyKey, value);
+    }
+  };
 
   const normalizeValueBeforeSave = (key, value) => {
     if (key === 'global.thinking_model_blacklist') {
       const text = typeof value === 'string' ? value.trim() : '';
       return text === '' ? '[]' : value;
     }
+    if (key === 'global.chat_completions_to_responses_policy') {
+      const text = typeof value === 'string' ? value.trim() : '';
+      return text === '' ? '{}' : value;
+    }
     return value;
   };
 
@@ -108,6 +155,16 @@ export default function SettingGlobalModel(props) {
             value = defaultGlobalSettingInputs[key];
           }
         }
+        if (key === 'global.chat_completions_to_responses_policy') {
+          try {
+            value =
+              value && String(value).trim() !== ''
+                ? JSON.stringify(JSON.parse(value), null, 2)
+                : defaultGlobalSettingInputs[key];
+          } catch (error) {
+            value = defaultGlobalSettingInputs[key];
+          }
+        }
         currentInputs[key] = value;
       } else {
         currentInputs[key] = defaultGlobalSettingInputs[key];
@@ -180,7 +237,134 @@ export default function SettingGlobalModel(props) {
               </Col>
             </Row>
 
-            <Form.Section text={t('连接保活设置')}>
+            <Form.Section
+              text={
+                <span
+                  style={{
+                    fontSize: 14,
+                    fontWeight: 600,
+                    display: 'inline-flex',
+                    alignItems: 'center',
+                    gap: 8,
+                    flexWrap: 'wrap',
+                  }}
+                >
+                  {t('ChatCompletions→Responses 兼容配置')}
+                  <Tag color='orange' size='small'>
+                    测试版
+                  </Tag>
+                </span>
+              }
+            >
+              <Row style={{ marginTop: 10 }}>
+                <Col span={24}>
+                  <Banner
+                    type='warning'
+                    description={t(
+                      '提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。',
+                    )}
+                  />
+                </Col>
+              </Row>
+
+              <Row style={{ marginTop: 10 }}>
+                <Col span={24}>
+	                  <Form.TextArea
+	                    label={t('参数配置')}
+	                    field={chatCompletionsToResponsesPolicyKey}
+	                    placeholder={
+	                      t('例如(指定渠道):') +
+	                      '\n' +
+                      chatCompletionsToResponsesPolicyExample +
+                      '\n\n' +
+                      t('例如(全渠道):') +
+                      '\n' +
+                      chatCompletionsToResponsesPolicyAllChannelsExample
+                    }
+                    rows={8}
+                    rules={[
+                      {
+                        validator: (rule, value) => {
+                          if (!value || value.trim() === '') return true;
+                          return verifyJSON(value);
+                        },
+                        message: t('不是合法的 JSON 字符串'),
+                      },
+                    ]}
+                    onChange={(value) =>
+                      setInputs((prev) => ({
+                        ...prev,
+                        [chatCompletionsToResponsesPolicyKey]: value,
+                      }))
+                    }
+                  />
+                </Col>
+              </Row>
+
+              <Row style={{ marginTop: 10, marginBottom: 16 }}>
+                <Col span={24}>
+                  <div
+                    style={{
+                      display: 'flex',
+                      gap: 8,
+                      flexWrap: 'wrap',
+                      alignItems: 'center',
+                    }}
+                  >
+                    <Button
+                      type='secondary'
+                      size='small'
+                      onClick={() =>
+                        setChatCompletionsToResponsesPolicyValue(
+                          chatCompletionsToResponsesPolicyExample,
+                        )
+                      }
+                    >
+                      {t('填充模板(指定渠道)')}
+                    </Button>
+                    <Button
+                      type='secondary'
+                      size='small'
+                      onClick={() =>
+                        setChatCompletionsToResponsesPolicyValue(
+                          chatCompletionsToResponsesPolicyAllChannelsExample,
+                        )
+                      }
+                    >
+                      {t('填充模板(全渠道)')}
+                    </Button>
+                    <Button
+                      type='secondary'
+                      size='small'
+                      onClick={() => {
+                        const raw = inputs[chatCompletionsToResponsesPolicyKey];
+                        if (!raw || String(raw).trim() === '') return;
+                        try {
+                          const formatted = JSON.stringify(
+                            JSON.parse(raw),
+                            null,
+                            2,
+                          );
+                          setChatCompletionsToResponsesPolicyValue(formatted);
+                        } catch (error) {
+                          showError(t('不是合法的 JSON 字符串'));
+                        }
+                      }}
+                    >
+                      {t('格式化 JSON')}
+                    </Button>
+                  </div>
+                </Col>
+              </Row>
+            </Form.Section>
+
+            <Form.Section
+              text={
+                <span style={{ fontSize: 14, fontWeight: 600 }}>
+                  {t('连接保活设置')}
+                </span>
+              }
+            >
               <Row style={{ marginTop: 10 }}>
                 <Col span={24}>
                   <Banner

+ 64 - 3
web/src/pages/Setting/Operation/SettingsMonitoring.jsx

@@ -18,16 +18,23 @@ For commercial licensing, please contact support@quantumnous.com
 */
 
 import React, { useEffect, useState, useRef } from 'react';
-import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Col,
+  Form,
+  Row,
+  Spin,
+} from '@douyinfe/semi-ui';
 import {
   compareObjects,
   API,
   showError,
   showSuccess,
   showWarning,
-  verifyJSON,
+  parseHttpStatusCodeRules,
 } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
+import HttpStatusCodeRulesInput from '../../../components/settings/HttpStatusCodeRulesInput';
 
 export default function SettingsMonitoring(props) {
   const { t } = useTranslation();
@@ -38,21 +45,49 @@ export default function SettingsMonitoring(props) {
     AutomaticDisableChannelEnabled: false,
     AutomaticEnableChannelEnabled: false,
     AutomaticDisableKeywords: '',
+    AutomaticDisableStatusCodes: '401',
+    AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
     'monitor_setting.auto_test_channel_enabled': false,
     'monitor_setting.auto_test_channel_minutes': 10,
   });
   const refForm = useRef();
   const [inputsRow, setInputsRow] = useState(inputs);
+  const parsedAutoDisableStatusCodes = parseHttpStatusCodeRules(
+    inputs.AutomaticDisableStatusCodes || '',
+  );
+  const parsedAutoRetryStatusCodes = parseHttpStatusCodeRules(
+    inputs.AutomaticRetryStatusCodes || '',
+  );
 
   function onSubmit() {
     const updateArray = compareObjects(inputs, inputsRow);
     if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
+    if (!parsedAutoDisableStatusCodes.ok) {
+      const details =
+        parsedAutoDisableStatusCodes.invalidTokens &&
+        parsedAutoDisableStatusCodes.invalidTokens.length > 0
+          ? `: ${parsedAutoDisableStatusCodes.invalidTokens.join(', ')}`
+          : '';
+      return showError(`${t('自动禁用状态码格式不正确')}${details}`);
+    }
+    if (!parsedAutoRetryStatusCodes.ok) {
+      const details =
+        parsedAutoRetryStatusCodes.invalidTokens &&
+        parsedAutoRetryStatusCodes.invalidTokens.length > 0
+          ? `: ${parsedAutoRetryStatusCodes.invalidTokens.join(', ')}`
+          : '';
+      return showError(`${t('自动重试状态码格式不正确')}${details}`);
+    }
     const requestQueue = updateArray.map((item) => {
       let value = '';
       if (typeof inputs[item.key] === 'boolean') {
         value = String(inputs[item.key]);
       } else {
-        value = inputs[item.key];
+        const normalizedMap = {
+          AutomaticDisableStatusCodes: parsedAutoDisableStatusCodes.normalized,
+          AutomaticRetryStatusCodes: parsedAutoRetryStatusCodes.normalized,
+        };
+        value = normalizedMap[item.key] ?? inputs[item.key];
       }
       return API.put('/api/option/', {
         key: item.key,
@@ -207,6 +242,32 @@ export default function SettingsMonitoring(props) {
             </Row>
             <Row gutter={16}>
               <Col xs={24} sm={16}>
+                <HttpStatusCodeRulesInput
+                  label={t('自动禁用状态码')}
+                  placeholder={t('例如:401, 403, 429, 500-599')}
+                  extraText={t(
+                    '支持填写单个状态码或范围(含首尾),使用逗号分隔',
+                  )}
+                  field={'AutomaticDisableStatusCodes'}
+                  onChange={(value) =>
+                    setInputs({ ...inputs, AutomaticDisableStatusCodes: value })
+                  }
+                  parsed={parsedAutoDisableStatusCodes}
+                  invalidText={t('自动禁用状态码格式不正确')}
+                />
+                <HttpStatusCodeRulesInput
+                  label={t('自动重试状态码')}
+                  placeholder={t('例如:401, 403, 429, 500-599')}
+                  extraText={t(
+                    '支持填写单个状态码或范围(含首尾),使用逗号分隔',
+                  )}
+                  field={'AutomaticRetryStatusCodes'}
+                  onChange={(value) =>
+                    setInputs({ ...inputs, AutomaticRetryStatusCodes: value })
+                  }
+                  parsed={parsedAutoRetryStatusCodes}
+                  invalidText={t('自动重试状态码格式不正确')}
+                />
                 <Form.TextArea
                   label={t('自动禁用关键词')}
                   placeholder={t('一行一个,不区分大小写')}