Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions constant/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ const (
APITypeSubmodel
APITypeMiniMax
APITypeReplicate
APITypeCodex
APITypeDummy // this one is only for count, do not add any channel after this
)
3 changes: 3 additions & 0 deletions constant/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

)
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

OpenAI Codex API endpoint URL documentation

💡 Result:

Short answer: OpenAI’s Codex/docs live on OpenAI’s developer sites; Codex uses the standard OpenAI REST API base URL (https://api.openai.com/v1/) and Codex-specific guidance is in the Codex docs.

Where to read:

  • Codex developer docs (features, CLI/IDE integration): developers.openai.com/codex. [1]
  • Platform Codex overview & guidance: platform.openai.com/docs/codex. [2]
  • General OpenAI API reference (base endpoint and REST endpoints like /v1/models, /v1/chat/completions, /v1/files): platform.openai.com/docs/api-reference (base URL: https://api.openai.com/v1/). [3]

References:
[1] developers.openai.com/codex.
[2] platform.openai.com/docs/codex.
[3] platform.openai.com/docs/api-reference/introduction.


🏁 Script executed:

# First, let's examine the constant/channel.go file to understand the context
head -n 150 constant/channel.go | tail -n 40

Repository: QuantumNous/new-api

Length of output: 1666


🏁 Script executed:

# Search for where this URL constant is used in the codebase
rg "chatgpt\.com" -B 2 -A 2

Repository: QuantumNous/new-api

Length of output: 318


🏁 Script executed:

# Search for the channel type constant that corresponds to position 57
rg "ChannelType" constant/channel.go | head -n 70 | tail -n 20

Repository: QuantumNous/new-api

Length of output: 876


Fix the Codex channel base URL to use the correct API endpoint.

The URL https://chatgpt.com at line 120 is the ChatGPT web interface, not an API endpoint. According to OpenAI's official documentation, the Codex API uses https://api.openai.com/v1/ as the base endpoint, matching the OpenAI channel at line 119. Requests to chatgpt.com will fail and cannot access the Codex API.

🤖 Prompt for AI Agents
In @constant/channel.go at line 120, Replace the incorrect Codex channel base
URL string "https://chatgpt.com" with the proper OpenAI API base
"https://api.openai.com/v1/" so the Codex channel uses the same API endpoint as
the OpenAI channel; locate the entry in the channel list containing
"https://chatgpt.com" (the Codex channel) in constant/channel.go and update that
string to "https://api.openai.com/v1/".

}

var ChannelTypeNames = map[int]string{
Expand Down Expand Up @@ -172,6 +174,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeDoubaoVideo: "DoubaoVideo",
ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate",
ChannelTypeCodex: "Codex",
}

func GetChannelTypeName(channelType int) string {
Expand Down
53 changes: 53 additions & 0 deletions controller/channel.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"`
Expand Down
243 changes: 243 additions & 0 deletions controller/codex_oauth.go
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +206 to +216

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Session cleanup occurs before DB operation - potential state inconsistency.

If the DB update (line 212) fails, the session has already been cleared (lines 206-209), preventing the user from retrying without restarting the OAuth flow. Consider moving session cleanup after the DB operation succeeds.

🔧 Proposed fix
-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
     }
+    session.Delete(codexOAuthSessionKey(channelID, "state"))
+    session.Delete(codexOAuthSessionKey(channelID, "verifier"))
+    session.Delete(codexOAuthSessionKey(channelID, "created_at"))
+    _ = session.Save()
     model.InitChannelCache()
     service.ResetProxyClientCache()
     // ... rest of response
     return
 }

+session.Delete(codexOAuthSessionKey(channelID, "state"))
+session.Delete(codexOAuthSessionKey(channelID, "verifier"))
+session.Delete(codexOAuthSessionKey(channelID, "created_at"))
+_ = session.Save()
+
 c.JSON(http.StatusOK, gin.H{
     // ... response for non-channel case
 })
🤖 Prompt for AI Agents
In @controller/codex_oauth.go around lines 206 - 216, Session keys are being
deleted before the DB update, which can leave the user unable to retry if
model.DB.Model(&model.Channel{}).Where("id = ?", channelID).Update("key",
string(encoded)).Error fails; move the session.Delete calls (and session.Save)
to after the DB update succeeds and model.InitChannelCache() completes so the
session is only cleared on success, keeping codexOAuthSessionKey(..., "state"),
codexOAuthSessionKey(..., "verifier"), and codexOAuthSessionKey(...,
"created_at") intact if the update returns an error.

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,
},
})
}
Loading