From e5cb9ac03a08d34bf3afaf1742b795c48941db74 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:29:43 +0800 Subject: [PATCH] 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 --- common/api_type.go | 2 + constant/api_type.go | 1 + constant/channel.go | 3 + controller/channel.go | 53 ++++ controller/codex_oauth.go | 243 +++++++++++++++ controller/codex_usage.go | 124 ++++++++ dto/error.go | 6 +- dto/openai_response.go | 4 + main.go | 3 + relay/channel/codex/adaptor.go | 161 ++++++++++ relay/channel/codex/constants.go | 9 + relay/channel/codex/oauth_key.go | 30 ++ relay/channel/openai/chat_via_responses.go | 183 +++++++++-- relay/common/relay_info.go | 1 + relay/compatible_handler.go | 17 +- relay/relay_adaptor.go | 3 + router/api-router.go | 6 + service/codex_credential_refresh.go | 104 +++++++ service/codex_credential_refresh_task.go | 140 +++++++++ service/codex_oauth.go | 288 ++++++++++++++++++ service/codex_wham_usage.go | 56 ++++ service/openaicompat/chat_to_responses.go | 96 +++++- .../table/channels/modals/CodexOAuthModal.jsx | 151 +++++++++ .../table/channels/modals/CodexUsageModal.jsx | 190 ++++++++++++ .../channels/modals/EditChannelModal.jsx | 177 ++++++++++- web/src/constants/channel.constants.js | 5 + web/src/helpers/render.jsx | 1 + web/src/hooks/channels/useChannelsData.jsx | 27 ++ 28 files changed, 2052 insertions(+), 32 deletions(-) create mode 100644 controller/codex_oauth.go create mode 100644 controller/codex_usage.go create mode 100644 relay/channel/codex/adaptor.go create mode 100644 relay/channel/codex/constants.go create mode 100644 relay/channel/codex/oauth_key.go create mode 100644 service/codex_credential_refresh.go create mode 100644 service/codex_credential_refresh_task.go create mode 100644 service/codex_oauth.go create mode 100644 service/codex_wham_usage.go create mode 100644 web/src/components/table/channels/modals/CodexOAuthModal.jsx create mode 100644 web/src/components/table/channels/modals/CodexUsageModal.jsx diff --git a/common/api_type.go b/common/api_type.go index 4f5c1826a..39c1fe9a5 100644 --- a/common/api_type.go +++ b/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 diff --git a/constant/api_type.go b/constant/api_type.go index 32b48bcd2..536ebd2c7 100644 --- a/constant/api_type.go +++ b/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 ) diff --git a/constant/channel.go b/constant/channel.go index 6d3a5d928..48502bedc 100644 --- a/constant/channel.go +++ b/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 { diff --git a/controller/channel.go b/controller/channel.go index cb97aa8cd..3ac29d7c6 100644 --- a/controller/channel.go +++ b/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"` diff --git a/controller/codex_oauth.go b/controller/codex_oauth.go new file mode 100644 index 000000000..3c881ebb5 --- /dev/null +++ b/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, + }, + }) +} diff --git a/controller/codex_usage.go b/controller/codex_usage.go new file mode 100644 index 000000000..61614b460 --- /dev/null +++ b/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) +} diff --git a/dto/error.go b/dto/error.go index 78197765b..be57407f9 100644 --- a/dto/error.go +++ b/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 } diff --git a/dto/openai_response.go b/dto/openai_response.go index 16531e206..19ca92905 100644 --- a/dto/openai_response.go +++ b/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结构 diff --git a/main.go b/main.go index 4c0fc8c6e..1326b1227 100644 --- a/main.go +++ b/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() diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go new file mode 100644 index 000000000..92d855a55 --- /dev/null +++ b/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 +} diff --git a/relay/channel/codex/constants.go b/relay/channel/codex/constants.go new file mode 100644 index 000000000..461e033a4 --- /dev/null +++ b/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" diff --git a/relay/channel/codex/oauth_key.go b/relay/channel/codex/oauth_key.go new file mode 100644 index 000000000..bf143f81f --- /dev/null +++ b/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 +} diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 5965613c1..83f9734c9 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/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) } diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 56f16fbf2..4665573dd 100644 --- a/relay/common/relay_info.go +++ b/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 { diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index d20dc93ac..6c36f83d2 100644 --- a/relay/compatible_handler.go +++ b/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{ diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 9afa3b0cb..3139c9a2d 100644 --- a/relay/relay_adaptor.go +++ b/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 } diff --git a/router/api-router.go b/router/api-router.go index 9b2bd0615..f3ae4d970 100644 --- a/router/api-router.go +++ b/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) diff --git a/service/codex_credential_refresh.go b/service/codex_credential_refresh.go new file mode 100644 index 000000000..0290fe516 --- /dev/null +++ b/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 +} diff --git a/service/codex_credential_refresh_task.go b/service/codex_credential_refresh_task.go new file mode 100644 index 000000000..627ab9295 --- /dev/null +++ b/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) + } +} diff --git a/service/codex_oauth.go b/service/codex_oauth.go new file mode 100644 index 000000000..4c2dce1cc --- /dev/null +++ b/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 +} diff --git a/service/codex_wham_usage.go b/service/codex_wham_usage.go new file mode 100644 index 000000000..d27cbd9dc --- /dev/null +++ b/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 +} diff --git a/service/openaicompat/chat_to_responses.go b/service/openaicompat/chat_to_responses.go index ddcc9f28b..3779db934 100644 --- a/service/openaicompat/chat_to_responses.go +++ b/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) diff --git a/web/src/components/table/channels/modals/CodexOAuthModal.jsx b/web/src/components/table/channels/modals/CodexOAuthModal.jsx new file mode 100644 index 000000000..cdd4d768f --- /dev/null +++ b/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 . + +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 ( + + + + + } + > + + + + + + + + + setInput(value)} + placeholder={t('请粘贴完整回调 URL(包含 code 与 state)')} + showClear + /> + + + {t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')} + + + + ); +}; + +export default CodexOAuthModal; diff --git a/web/src/components/table/channels/modals/CodexUsageModal.jsx b/web/src/components/table/channels/modals/CodexUsageModal.jsx new file mode 100644 index 000000000..df5e2c98b --- /dev/null +++ b/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 . + +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 ( +
+
+
{title}
+ + {tt('重置时间:')} + {formatUnixSeconds(resetAt)} + +
+ +
+ +
+ +
+
+ {tt('已使用:')} + {percent}% +
+
+ {tt('距离重置:')} + {formatDurationSeconds(resetAfterSeconds, tt)} +
+
+ {tt('窗口:')} + {formatDurationSeconds(limitWindowSeconds, tt)} +
+
+
+ ); +}; + +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 ? ( + {tt('可用')} + ) : ( + {tt('受限')} + ); + + const rawText = + typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2); + + Modal.info({ + title: ( +
+ {tt('Codex 用量')} + {statusTag} +
+ ), + centered: true, + width: 900, + style: { maxWidth: '95vw' }, + content: ( +
+
+ + {tt('渠道:')} + {record?.name || '-'} ({tt('编号:')} + {record?.id || '-'}) + + + {tt('上游状态码:')} + {upstreamStatus ?? '-'} + +
+ +
+ + +
+ +
+
+
{tt('原始 JSON')}
+ +
+
+            {rawText}
+          
+
+
+ ), + footer: ( +
+ +
+ ), + }); +}; diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 722c1e8a9..0c813fc0f 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/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 ? ( {!isEdit && ( @@ -1884,8 +1969,94 @@ const EditChannelModal = (props) => { ) ) : ( <> - {inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( + {inputs.type === 57 ? ( + <> + handleInputChange('key', value)} + disabled={isIonetLocked} + extraText={ +
+ + {t( + '仅支持 JSON 对象,必须包含 access_token 与 account_id', + )} + + + + + {isEdit && ( + + )} + + {isEdit && ( + + )} + {batchExtra} + +
+ } + autosize + showClear + /> + + setCodexOAuthModalVisible(false)} + onSuccess={handleCodexOAuthGenerated} + /> + + ) : inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( <> {!batch && (
diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 0d487958e..ce2f6cd47 100644 --- a/web/src/constants/channel.constants.js +++ b/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; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 4be021866..36aa7cbe9 100644 --- a/web/src/helpers/render.jsx +++ b/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 ; case 2: // Midjourney Proxy case 5: // Midjourney Proxy Plus diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 415a34a5d..5e1feb162 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/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) {