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
This commit is contained in:
Seefs
2026-01-14 22:29:43 +08:00
committed by GitHub
parent ca11fcbabd
commit e5cb9ac03a
28 changed files with 2052 additions and 32 deletions

View File

@@ -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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
@@ -604,9 +606,60 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
}
}
// Codex OAuth key validation (optional, only when JSON object is provided)
if channel.Type == constant.ChannelTypeCodex {
trimmedKey := strings.TrimSpace(channel.Key)
if isAdd || trimmedKey != "" {
if !strings.HasPrefix(trimmedKey, "{") {
return fmt.Errorf("Codex key must be a valid JSON object")
}
var keyMap map[string]any
if err := common.Unmarshal([]byte(trimmedKey), &keyMap); err != nil {
return fmt.Errorf("Codex key must be a valid JSON object")
}
if v, ok := keyMap["access_token"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" {
return fmt.Errorf("Codex key JSON must include access_token")
}
if v, ok := keyMap["account_id"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" {
return fmt.Errorf("Codex key JSON must include account_id")
}
}
}
return nil
}
func RefreshCodexChannelCredential(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("invalid channel id: %w", err))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "refreshed",
"data": gin.H{
"expires_at": oauthKey.Expired,
"last_refresh": oauthKey.LastRefresh,
"account_id": oauthKey.AccountID,
"email": oauthKey.Email,
"channel_id": ch.Id,
"channel_type": ch.Type,
"channel_name": ch.Name,
},
})
}
type AddChannelRequest struct {
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`

243
controller/codex_oauth.go Normal file
View File

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

124
controller/codex_usage.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

288
service/codex_oauth.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,6 +56,7 @@ import {
} from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal';
import OllamaModelModal from './OllamaModelModal';
import CodexOAuthModal from './CodexOAuthModal';
import JSONEditor from '../../../common/ui/JSONEditor';
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
@@ -114,6 +115,8 @@ function type2secretPrompt(type) {
return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API则直接输ApiKey';
case 51:
return '按照如下格式输入: AccessKey|SecretAccessKey';
case 57:
return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id';
default:
return '请输入渠道对应的鉴权密钥';
}
@@ -212,6 +215,9 @@ const EditChannelModal = (props) => {
}, [inputs.model_mapping]);
const [isIonetChannel, setIsIonetChannel] = useState(false);
const [ionetMetadata, setIonetMetadata] = useState(null);
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
useState(false);
// 密钥显示状态
const [keyDisplayState, setKeyDisplayState] = useState({
@@ -499,6 +505,18 @@ const EditChannelModal = (props) => {
// 重置手动输入模式状态
setUseManualInput(false);
if (value === 57) {
setBatch(false);
setMultiToSingle(false);
setMultiKeyMode('random');
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue('vertex_files', []);
}
setInputs((prev) => ({ ...prev, vertex_files: [] }));
}
}
//setAutoBan
};
@@ -822,6 +840,32 @@ const EditChannelModal = (props) => {
}
};
const handleCodexOAuthGenerated = (key) => {
handleInputChange('key', key);
formatJsonField('key');
};
const handleRefreshCodexCredential = async () => {
if (!isEdit) return;
setCodexCredentialRefreshing(true);
try {
const res = await API.post(
`/api/channel/${channelId}/codex/refresh`,
{},
{ skipErrorHandler: true },
);
if (!res?.data?.success) {
throw new Error(res?.data?.message || 'Failed to refresh credential');
}
showSuccess(t('凭证已刷新'));
} catch (error) {
showError(error.message || t('刷新失败'));
} finally {
setCodexCredentialRefreshing(false);
}
};
useEffect(() => {
if (inputs.type !== 45) {
doubaoApiClickCountRef.current = 0;
@@ -1070,6 +1114,47 @@ const EditChannelModal = (props) => {
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
let localInputs = { ...formValues };
if (localInputs.type === 57) {
if (batch) {
showInfo(t('Codex 渠道不支持批量创建'));
return;
}
const rawKey = (localInputs.key || '').trim();
if (!isEdit && rawKey === '') {
showInfo(t('请输入密钥!'));
return;
}
if (rawKey !== '') {
if (!verifyJSON(rawKey)) {
showInfo(t('密钥必须是合法的 JSON 格式!'));
return;
}
try {
const parsed = JSON.parse(rawKey);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
showInfo(t('密钥必须是 JSON 对象'));
return;
}
const accessToken = String(parsed.access_token || '').trim();
const accountId = String(parsed.account_id || '').trim();
if (!accessToken) {
showInfo(t('密钥 JSON 必须包含 access_token'));
return;
}
if (!accountId) {
showInfo(t('密钥 JSON 必须包含 account_id'));
return;
}
localInputs.key = JSON.stringify(parsed);
} catch (error) {
showInfo(t('密钥必须是合法的 JSON 格式!'));
return;
}
}
}
if (localInputs.type === 41) {
const keyType = localInputs.vertex_key_type || 'json';
if (keyType === 'api_key') {
@@ -1401,7 +1486,7 @@ const EditChannelModal = (props) => {
}
};
const batchAllowed = !isEdit || isMultiKeyChannel;
const batchAllowed = (!isEdit || isMultiKeyChannel) && inputs.type !== 57;
const batchExtra = batchAllowed ? (
<Space>
{!isEdit && (
@@ -1884,8 +1969,94 @@ const EditChannelModal = (props) => {
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
{inputs.type === 57 ? (
<>
<Form.TextArea
field='key'
label={
isEdit
? t('密钥(编辑模式下,保存的密钥不会显示)')
: t('密钥')
}
placeholder={t(
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
)}
rules={
isEdit
? []
: [{ required: true, message: t('请输入密钥') }]
}
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
disabled={isIonetLocked}
extraText={
<div className='flex flex-col gap-2'>
<Text type='tertiary' size='small'>
{t(
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
)}
</Text>
<Space wrap spacing='tight'>
<Button
size='small'
type='primary'
theme='outline'
onClick={() =>
setCodexOAuthModalVisible(true)
}
disabled={isIonetLocked}
>
{t('Codex 授权')}
</Button>
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleRefreshCodexCredential}
loading={codexCredentialRefreshing}
disabled={isIonetLocked}
>
{t('刷新凭证')}
</Button>
)}
<Button
size='small'
type='primary'
theme='outline'
onClick={() => formatJsonField('key')}
disabled={isIonetLocked}
>
{t('格式化')}
</Button>
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
disabled={isIonetLocked}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</Space>
</div>
}
autosize
showClear
/>
<CodexOAuthModal
visible={codexOAuthModalVisible}
onCancel={() => setCodexOAuthModalVisible(false)}
onSuccess={handleCodexOAuthGenerated}
/>
</>
) : inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>

View File

@@ -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;

View File

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

View File

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