Bladeren bron

feat: codex channel (#2652)

* feat: codex channel

* feat: codex channel

* feat: codex oauth flow

* feat: codex refresh cred

* feat: codex usage

* fix: codex err message detail

* fix: codex setting ui

* feat: codex refresh cred task

* fix: import err

* fix: codex store must be false

* fix: chat -> responses tool call

* fix: chat -> responses tool call
Seefs 1 week geleden
bovenliggende
commit
e5cb9ac03a

+ 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

+ 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 {

+ 53 - 0
controller/channel.go

@@ -1,11 +1,13 @@
 package controller
 
 import (
+	"context"
 	"encoding/json"
 	"fmt"
 	"net/http"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/constant"
@@ -604,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"`

+ 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)
+}

+ 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
 	}

+ 4 - 0
dto/openai_response.go

@@ -372,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结构

+ 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()

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

@@ -0,0 +1,161 @@
+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")
+	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
+}

+ 159 - 24
relay/channel/openai/chat_via_responses.go

@@ -26,14 +26,10 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
 	defer service.CloseResponseBodyGracefully(resp)
 
 	var responsesResp dto.OpenAIResponsesResponse
-	const maxResponseBodyBytes = 10 << 20 // 10MB
-	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes+1))
+	body, err := io.ReadAll(resp.Body)
 	if err != nil {
 		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
-	if int64(len(body)) > maxResponseBodyBytes {
-		return nil, types.NewOpenAIError(fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
-	}
 
 	if err := common.Unmarshal(body, &responsesResp); err != nil {
 		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
@@ -77,12 +73,99 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
 
 	var (
 		usage       = &dto.Usage{}
-		textBuilder strings.Builder
+		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
@@ -106,16 +189,13 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
 			}
 
 		case "response.output_text.delta":
-			if !sentStart {
-				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
+			if !sendStartIfNeeded() {
+				return false
 			}
 
 			if streamResp.Delta != "" {
-				textBuilder.WriteString(streamResp.Delta)
+				outputText.WriteString(streamResp.Delta)
+				usageText.WriteString(streamResp.Delta)
 				delta := streamResp.Delta
 				chunk := &dto.ChatCompletionsStreamResponse{
 					Id:      responseId,
@@ -137,6 +217,59 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
 				}
 			}
 
+		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 != "" {
@@ -170,15 +303,15 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
 				}
 			}
 
-			if !sentStart {
-				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
+			if !sendStartIfNeeded() {
+				return false
 			}
 			if !sentStop {
-				stop := helper.GenerateStopResponse(responseId, createAt, model, "stop")
+				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
@@ -196,8 +329,6 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
 			streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
 			return false
 
-		case "response.output_item.added", "response.output_item.done":
-
 		default:
 		}
 
@@ -209,7 +340,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
 	}
 
 	if usage.TotalTokens == 0 {
-		usage = service.ResponseText2Usage(c, textBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
+		usage = service.ResponseText2Usage(c, usageText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
 	}
 
 	if !sentStart {
@@ -218,7 +349,11 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
 		}
 	}
 	if !sentStop {
-		stop := helper.GenerateStopResponse(responseId, createAt, model, "stop")
+		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)
 		}

+ 1 - 0
relay/common/relay_info.go

@@ -274,6 +274,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 {

+ 14 - 3
relay/compatible_handler.go

@@ -75,10 +75,11 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
 	}
 	adaptor.Init(info)
 
+	passThroughGlobal := model_setting.GetGlobalSettings().PassThroughRequestEnabled
 	if info.RelayMode == relayconstant.RelayModeChatCompletions &&
-		!model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
+		!passThroughGlobal &&
 		!info.ChannelSetting.PassThroughBodyEnabled &&
-		service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName) {
+		shouldChatCompletionsViaResponses(info) {
 		applySystemPromptIfNeeded(c, info, request)
 		usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
 		if newApiErr != nil {
@@ -98,7 +99,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
 
 	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())
@@ -216,6 +217,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{

+ 3 - 0
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
 }

+ 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)

+ 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
+}

+ 95 - 1
service/openaicompat/chat_to_responses.go

@@ -54,6 +54,38 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
 			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 {
@@ -88,12 +120,54 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
 		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
 		}
 
@@ -127,7 +201,6 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
 					"video_url": part.VideoUrl,
 				})
 			default:
-				// Best-effort: keep unknown parts as-is to avoid silently dropping context.
 				contentParts = append(contentParts, map[string]any{
 					"type": part.Type,
 				})
@@ -135,6 +208,27 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
 		}
 		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)

+ 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 [email protected]
+*/
+
+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 [email protected]
+*/
+
+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>
+    ),
+  });
+};

+ 174 - 3
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -56,6 +56,7 @@ import {
 } from '../../../../helpers';
 import ModelSelectModal from './ModelSelectModal';
 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';
@@ -114,6 +115,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 '请输入渠道对应的鉴权密钥';
   }
@@ -212,6 +215,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({
@@ -499,6 +505,18 @@ 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
   };
@@ -822,6 +840,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;
@@ -1070,6 +1114,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') {
@@ -1401,7 +1486,7 @@ const EditChannelModal = (props) => {
     }
   };
 
-  const batchAllowed = !isEdit || isMultiKeyChannel;
+  const batchAllowed = (!isEdit || isMultiKeyChannel) && inputs.type !== 57;
   const batchExtra = batchAllowed ? (
     <Space>
       {!isEdit && (
@@ -1884,8 +1969,94 @@ const EditChannelModal = (props) => {
                     )
                   ) : (
                     <>
-                      {inputs.type === 41 &&
-                      (inputs.vertex_key_type || 'json') === 'json' ? (
+                      {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 wrap spacing='tight'>
+                                  <Button
+                                    size='small'
+                                    type='primary'
+                                    theme='outline'
+                                    onClick={() =>
+                                      setCodexOAuthModalVisible(true)
+                                    }
+                                    disabled={isIonetLocked}
+                                  >
+                                    {t('Codex 授权')}
+                                  </Button>
+                                  {isEdit && (
+                                    <Button
+                                      size='small'
+                                      type='primary'
+                                      theme='outline'
+                                      onClick={handleRefreshCodexCredential}
+                                      loading={codexCredentialRefreshing}
+                                      disabled={isIonetLocked}
+                                    >
+                                      {t('刷新凭证')}
+                                    </Button>
+                                  )}
+                                  <Button
+                                    size='small'
+                                    type='primary'
+                                    theme='outline'
+                                    onClick={() => formatJsonField('key')}
+                                    disabled={isIonetLocked}
+                                  >
+                                    {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'>

+ 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/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

+ 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) {