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