mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 09:28:37 +00:00
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:
@@ -73,6 +73,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
|
|||||||
apiType = constant.APITypeMiniMax
|
apiType = constant.APITypeMiniMax
|
||||||
case constant.ChannelTypeReplicate:
|
case constant.ChannelTypeReplicate:
|
||||||
apiType = constant.APITypeReplicate
|
apiType = constant.APITypeReplicate
|
||||||
|
case constant.ChannelTypeCodex:
|
||||||
|
apiType = constant.APITypeCodex
|
||||||
}
|
}
|
||||||
if apiType == -1 {
|
if apiType == -1 {
|
||||||
return constant.APITypeOpenAI, false
|
return constant.APITypeOpenAI, false
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ const (
|
|||||||
APITypeSubmodel
|
APITypeSubmodel
|
||||||
APITypeMiniMax
|
APITypeMiniMax
|
||||||
APITypeReplicate
|
APITypeReplicate
|
||||||
|
APITypeCodex
|
||||||
APITypeDummy // this one is only for count, do not add any channel after this
|
APITypeDummy // this one is only for count, do not add any channel after this
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const (
|
|||||||
ChannelTypeDoubaoVideo = 54
|
ChannelTypeDoubaoVideo = 54
|
||||||
ChannelTypeSora = 55
|
ChannelTypeSora = 55
|
||||||
ChannelTypeReplicate = 56
|
ChannelTypeReplicate = 56
|
||||||
|
ChannelTypeCodex = 57
|
||||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
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://ark.cn-beijing.volces.com", //54
|
||||||
"https://api.openai.com", //55
|
"https://api.openai.com", //55
|
||||||
"https://api.replicate.com", //56
|
"https://api.replicate.com", //56
|
||||||
|
"https://chatgpt.com", //57
|
||||||
}
|
}
|
||||||
|
|
||||||
var ChannelTypeNames = map[int]string{
|
var ChannelTypeNames = map[int]string{
|
||||||
@@ -172,6 +174,7 @@ var ChannelTypeNames = map[int]string{
|
|||||||
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||||
ChannelTypeSora: "Sora",
|
ChannelTypeSora: "Sora",
|
||||||
ChannelTypeReplicate: "Replicate",
|
ChannelTypeReplicate: "Replicate",
|
||||||
|
ChannelTypeCodex: "Codex",
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetChannelTypeName(channelType int) string {
|
func GetChannelTypeName(channelType int) string {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/constant"
|
"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
|
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 {
|
type AddChannelRequest struct {
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
|
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
|
||||||
|
|||||||
243
controller/codex_oauth.go
Normal file
243
controller/codex_oauth.go
Normal 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
124
controller/codex_usage.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -26,7 +26,8 @@ type GeneralErrorResponse struct {
|
|||||||
Msg string `json:"msg"`
|
Msg string `json:"msg"`
|
||||||
Err string `json:"err"`
|
Err string `json:"err"`
|
||||||
ErrorMsg string `json:"error_msg"`
|
ErrorMsg string `json:"error_msg"`
|
||||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
Header struct {
|
Header struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
} `json:"header"`
|
} `json:"header"`
|
||||||
@@ -79,6 +80,9 @@ func (e GeneralErrorResponse) ToMessage() string {
|
|||||||
if e.ErrorMsg != "" {
|
if e.ErrorMsg != "" {
|
||||||
return e.ErrorMsg
|
return e.ErrorMsg
|
||||||
}
|
}
|
||||||
|
if e.Detail != "" {
|
||||||
|
return e.Detail
|
||||||
|
}
|
||||||
if e.Header.Message != "" {
|
if e.Header.Message != "" {
|
||||||
return e.Header.Message
|
return e.Header.Message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,6 +372,10 @@ type ResponsesStreamResponse struct {
|
|||||||
Response *OpenAIResponsesResponse `json:"response,omitempty"`
|
Response *OpenAIResponsesResponse `json:"response,omitempty"`
|
||||||
Delta string `json:"delta,omitempty"`
|
Delta string `json:"delta,omitempty"`
|
||||||
Item *ResponsesOutput `json:"item,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结构
|
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
|
||||||
|
|||||||
3
main.go
3
main.go
@@ -102,6 +102,9 @@ func main() {
|
|||||||
|
|
||||||
go controller.AutomaticallyTestChannels()
|
go controller.AutomaticallyTestChannels()
|
||||||
|
|
||||||
|
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
|
||||||
|
service.StartCodexCredentialAutoRefreshTask()
|
||||||
|
|
||||||
if common.IsMasterNode && constant.UpdateTask {
|
if common.IsMasterNode && constant.UpdateTask {
|
||||||
gopool.Go(func() {
|
gopool.Go(func() {
|
||||||
controller.UpdateMidjourneyTaskBulk()
|
controller.UpdateMidjourneyTaskBulk()
|
||||||
|
|||||||
161
relay/channel/codex/adaptor.go
Normal file
161
relay/channel/codex/adaptor.go
Normal 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
|
||||||
|
}
|
||||||
9
relay/channel/codex/constants.go
Normal file
9
relay/channel/codex/constants.go
Normal 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"
|
||||||
30
relay/channel/codex/oauth_key.go
Normal file
30
relay/channel/codex/oauth_key.go
Normal 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
|
||||||
|
}
|
||||||
@@ -26,14 +26,10 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
|||||||
defer service.CloseResponseBodyGracefully(resp)
|
defer service.CloseResponseBodyGracefully(resp)
|
||||||
|
|
||||||
var responsesResp dto.OpenAIResponsesResponse
|
var responsesResp dto.OpenAIResponsesResponse
|
||||||
const maxResponseBodyBytes = 10 << 20 // 10MB
|
body, err := io.ReadAll(resp.Body)
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes+1))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
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 {
|
if err := common.Unmarshal(body, &responsesResp); err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
@@ -77,12 +73,99 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
usage = &dto.Usage{}
|
usage = &dto.Usage{}
|
||||||
textBuilder strings.Builder
|
outputText strings.Builder
|
||||||
|
usageText strings.Builder
|
||||||
sentStart bool
|
sentStart bool
|
||||||
sentStop bool
|
sentStop bool
|
||||||
|
sawToolCall bool
|
||||||
streamErr *types.NewAPIError
|
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 {
|
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||||
if streamErr != nil {
|
if streamErr != nil {
|
||||||
return false
|
return false
|
||||||
@@ -106,16 +189,13 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "response.output_text.delta":
|
case "response.output_text.delta":
|
||||||
if !sentStart {
|
if !sendStartIfNeeded() {
|
||||||
if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
|
return false
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sentStart = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if streamResp.Delta != "" {
|
if streamResp.Delta != "" {
|
||||||
textBuilder.WriteString(streamResp.Delta)
|
outputText.WriteString(streamResp.Delta)
|
||||||
|
usageText.WriteString(streamResp.Delta)
|
||||||
delta := streamResp.Delta
|
delta := streamResp.Delta
|
||||||
chunk := &dto.ChatCompletionsStreamResponse{
|
chunk := &dto.ChatCompletionsStreamResponse{
|
||||||
Id: responseId,
|
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":
|
case "response.completed":
|
||||||
if streamResp.Response != nil {
|
if streamResp.Response != nil {
|
||||||
if streamResp.Response.Model != "" {
|
if streamResp.Response.Model != "" {
|
||||||
@@ -170,15 +303,15 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !sentStart {
|
if !sendStartIfNeeded() {
|
||||||
if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
|
return false
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sentStart = true
|
|
||||||
}
|
}
|
||||||
if !sentStop {
|
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 {
|
if err := helper.ObjectData(c, stop); err != nil {
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
return false
|
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)
|
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
case "response.output_item.added", "response.output_item.done":
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +340,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if usage.TotalTokens == 0 {
|
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 {
|
if !sentStart {
|
||||||
@@ -218,7 +349,11 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !sentStop {
|
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 {
|
if err := helper.ObjectData(c, stop); err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ var streamSupportedChannels = map[int]bool{
|
|||||||
constant.ChannelTypeZhipu_v4: true,
|
constant.ChannelTypeZhipu_v4: true,
|
||||||
constant.ChannelTypeAli: true,
|
constant.ChannelTypeAli: true,
|
||||||
constant.ChannelTypeSubmodel: true,
|
constant.ChannelTypeSubmodel: true,
|
||||||
|
constant.ChannelTypeCodex: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
|
func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
|
||||||
|
|||||||
@@ -75,10 +75,11 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
|||||||
}
|
}
|
||||||
adaptor.Init(info)
|
adaptor.Init(info)
|
||||||
|
|
||||||
|
passThroughGlobal := model_setting.GetGlobalSettings().PassThroughRequestEnabled
|
||||||
if info.RelayMode == relayconstant.RelayModeChatCompletions &&
|
if info.RelayMode == relayconstant.RelayModeChatCompletions &&
|
||||||
!model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
|
!passThroughGlobal &&
|
||||||
!info.ChannelSetting.PassThroughBodyEnabled &&
|
!info.ChannelSetting.PassThroughBodyEnabled &&
|
||||||
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName) {
|
shouldChatCompletionsViaResponses(info) {
|
||||||
applySystemPromptIfNeeded(c, info, request)
|
applySystemPromptIfNeeded(c, info, request)
|
||||||
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
|
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
|
||||||
if newApiErr != nil {
|
if newApiErr != nil {
|
||||||
@@ -98,7 +99,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
|||||||
|
|
||||||
var requestBody io.Reader
|
var requestBody io.Reader
|
||||||
|
|
||||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
|
if passThroughGlobal || info.ChannelSetting.PassThroughBodyEnabled {
|
||||||
body, err := common.GetRequestBody(c)
|
body, err := common.GetRequestBody(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
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
|
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) {
|
func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
|
||||||
if usage == nil {
|
if usage == nil {
|
||||||
usage = &dto.Usage{
|
usage = &dto.Usage{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/relay/channel/baidu_v2"
|
"github.com/QuantumNous/new-api/relay/channel/baidu_v2"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/claude"
|
"github.com/QuantumNous/new-api/relay/channel/claude"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/cloudflare"
|
"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/cohere"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/coze"
|
"github.com/QuantumNous/new-api/relay/channel/coze"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/deepseek"
|
"github.com/QuantumNous/new-api/relay/channel/deepseek"
|
||||||
@@ -117,6 +118,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
|
|||||||
return &minimax.Adaptor{}
|
return &minimax.Adaptor{}
|
||||||
case constant.APITypeReplicate:
|
case constant.APITypeReplicate:
|
||||||
return &replicate.Adaptor{}
|
return &replicate.Adaptor{}
|
||||||
|
case constant.APITypeCodex:
|
||||||
|
return &codex.Adaptor{}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,12 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
channelRoute.POST("/fix", controller.FixChannelsAbilities)
|
channelRoute.POST("/fix", controller.FixChannelsAbilities)
|
||||||
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
|
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
|
||||||
channelRoute.POST("/fetch_models", controller.FetchModels)
|
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", controller.OllamaPullModel)
|
||||||
channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream)
|
channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream)
|
||||||
channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel)
|
channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel)
|
||||||
|
|||||||
104
service/codex_credential_refresh.go
Normal file
104
service/codex_credential_refresh.go
Normal 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
|
||||||
|
}
|
||||||
140
service/codex_credential_refresh_task.go
Normal file
140
service/codex_credential_refresh_task.go
Normal 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
288
service/codex_oauth.go
Normal 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
|
||||||
|
}
|
||||||
56
service/codex_wham_usage.go
Normal file
56
service/codex_wham_usage.go
Normal 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
|
||||||
|
}
|
||||||
@@ -54,6 +54,38 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
|
|||||||
continue
|
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`.
|
// Prefer mapping system/developer messages into `instructions`.
|
||||||
if role == "system" || role == "developer" {
|
if role == "system" || role == "developer" {
|
||||||
if msg.Content == nil {
|
if msg.Content == nil {
|
||||||
@@ -88,12 +120,54 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
|
|||||||
if msg.Content == nil {
|
if msg.Content == nil {
|
||||||
item["content"] = ""
|
item["content"] = ""
|
||||||
inputItems = append(inputItems, item)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.IsStringContent() {
|
if msg.IsStringContent() {
|
||||||
item["content"] = msg.StringContent()
|
item["content"] = msg.StringContent()
|
||||||
inputItems = append(inputItems, item)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +201,6 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
|
|||||||
"video_url": part.VideoUrl,
|
"video_url": part.VideoUrl,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
// Best-effort: keep unknown parts as-is to avoid silently dropping context.
|
|
||||||
contentParts = append(contentParts, map[string]any{
|
contentParts = append(contentParts, map[string]any{
|
||||||
"type": part.Type,
|
"type": part.Type,
|
||||||
})
|
})
|
||||||
@@ -135,6 +208,27 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
|
|||||||
}
|
}
|
||||||
item["content"] = contentParts
|
item["content"] = contentParts
|
||||||
inputItems = append(inputItems, item)
|
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)
|
inputRaw, err := common.Marshal(inputItems)
|
||||||
|
|||||||
151
web/src/components/table/channels/modals/CodexOAuthModal.jsx
Normal file
151
web/src/components/table/channels/modals/CodexOAuthModal.jsx
Normal 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;
|
||||||
190
web/src/components/table/channels/modals/CodexUsageModal.jsx
Normal file
190
web/src/components/table/channels/modals/CodexUsageModal.jsx
Normal 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>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
} from '../../../../helpers';
|
} from '../../../../helpers';
|
||||||
import ModelSelectModal from './ModelSelectModal';
|
import ModelSelectModal from './ModelSelectModal';
|
||||||
import OllamaModelModal from './OllamaModelModal';
|
import OllamaModelModal from './OllamaModelModal';
|
||||||
|
import CodexOAuthModal from './CodexOAuthModal';
|
||||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||||
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||||
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
||||||
@@ -114,6 +115,8 @@ function type2secretPrompt(type) {
|
|||||||
return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey';
|
return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey';
|
||||||
case 51:
|
case 51:
|
||||||
return '按照如下格式输入: AccessKey|SecretAccessKey';
|
return '按照如下格式输入: AccessKey|SecretAccessKey';
|
||||||
|
case 57:
|
||||||
|
return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id)';
|
||||||
default:
|
default:
|
||||||
return '请输入渠道对应的鉴权密钥';
|
return '请输入渠道对应的鉴权密钥';
|
||||||
}
|
}
|
||||||
@@ -212,6 +215,9 @@ const EditChannelModal = (props) => {
|
|||||||
}, [inputs.model_mapping]);
|
}, [inputs.model_mapping]);
|
||||||
const [isIonetChannel, setIsIonetChannel] = useState(false);
|
const [isIonetChannel, setIsIonetChannel] = useState(false);
|
||||||
const [ionetMetadata, setIonetMetadata] = useState(null);
|
const [ionetMetadata, setIonetMetadata] = useState(null);
|
||||||
|
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
|
||||||
|
const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// 密钥显示状态
|
// 密钥显示状态
|
||||||
const [keyDisplayState, setKeyDisplayState] = useState({
|
const [keyDisplayState, setKeyDisplayState] = useState({
|
||||||
@@ -499,6 +505,18 @@ const EditChannelModal = (props) => {
|
|||||||
|
|
||||||
// 重置手动输入模式状态
|
// 重置手动输入模式状态
|
||||||
setUseManualInput(false);
|
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
|
//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(() => {
|
useEffect(() => {
|
||||||
if (inputs.type !== 45) {
|
if (inputs.type !== 45) {
|
||||||
doubaoApiClickCountRef.current = 0;
|
doubaoApiClickCountRef.current = 0;
|
||||||
@@ -1070,6 +1114,47 @@ const EditChannelModal = (props) => {
|
|||||||
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
|
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
|
||||||
let localInputs = { ...formValues };
|
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) {
|
if (localInputs.type === 41) {
|
||||||
const keyType = localInputs.vertex_key_type || 'json';
|
const keyType = localInputs.vertex_key_type || 'json';
|
||||||
if (keyType === 'api_key') {
|
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 ? (
|
const batchExtra = batchAllowed ? (
|
||||||
<Space>
|
<Space>
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
@@ -1884,8 +1969,94 @@ const EditChannelModal = (props) => {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{inputs.type === 41 &&
|
{inputs.type === 57 ? (
|
||||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
<>
|
||||||
|
<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 && (
|
{!batch && (
|
||||||
<div className='flex items-center justify-between mb-3'>
|
<div className='flex items-center justify-between mb-3'>
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ export const CHANNEL_OPTIONS = [
|
|||||||
color: 'blue',
|
color: 'blue',
|
||||||
label: 'Replicate',
|
label: 'Replicate',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 57,
|
||||||
|
color: 'blue',
|
||||||
|
label: 'Codex (OpenAI OAuth)',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ export function getChannelIcon(channelType) {
|
|||||||
switch (channelType) {
|
switch (channelType) {
|
||||||
case 1: // OpenAI
|
case 1: // OpenAI
|
||||||
case 3: // Azure OpenAI
|
case 3: // Azure OpenAI
|
||||||
|
case 57: // Codex
|
||||||
return <OpenAI size={iconSize} />;
|
return <OpenAI size={iconSize} />;
|
||||||
case 2: // Midjourney Proxy
|
case 2: // Midjourney Proxy
|
||||||
case 5: // Midjourney Proxy Plus
|
case 5: // Midjourney Proxy Plus
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import { useIsMobile } from '../common/useIsMobile';
|
import { useIsMobile } from '../common/useIsMobile';
|
||||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||||
import { Modal, Button } from '@douyinfe/semi-ui';
|
import { Modal, Button } from '@douyinfe/semi-ui';
|
||||||
|
import { openCodexUsageModal } from '../../components/table/channels/modals/CodexUsageModal';
|
||||||
|
|
||||||
export const useChannelsData = () => {
|
export const useChannelsData = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -745,6 +746,32 @@ export const useChannelsData = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateChannelBalance = async (record) => {
|
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 res = await API.get(`/api/channel/update_balance/${record.id}/`);
|
||||||
const { success, message, balance } = res.data;
|
const { success, message, balance } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|||||||
Reference in New Issue
Block a user