mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 02:05:21 +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
|
||||
case constant.ChannelTypeReplicate:
|
||||
apiType = constant.APITypeReplicate
|
||||
case constant.ChannelTypeCodex:
|
||||
apiType = constant.APITypeCodex
|
||||
}
|
||||
if apiType == -1 {
|
||||
return constant.APITypeOpenAI, false
|
||||
|
||||
@@ -35,5 +35,6 @@ const (
|
||||
APITypeSubmodel
|
||||
APITypeMiniMax
|
||||
APITypeReplicate
|
||||
APITypeCodex
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ const (
|
||||
ChannelTypeDoubaoVideo = 54
|
||||
ChannelTypeSora = 55
|
||||
ChannelTypeReplicate = 56
|
||||
ChannelTypeCodex = 57
|
||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
||||
|
||||
)
|
||||
@@ -116,6 +117,7 @@ var ChannelBaseURLs = []string{
|
||||
"https://ark.cn-beijing.volces.com", //54
|
||||
"https://api.openai.com", //55
|
||||
"https://api.replicate.com", //56
|
||||
"https://chatgpt.com", //57
|
||||
}
|
||||
|
||||
var ChannelTypeNames = map[int]string{
|
||||
@@ -172,6 +174,7 @@ var ChannelTypeNames = map[int]string{
|
||||
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||
ChannelTypeSora: "Sora",
|
||||
ChannelTypeReplicate: "Replicate",
|
||||
ChannelTypeCodex: "Codex",
|
||||
}
|
||||
|
||||
func GetChannelTypeName(channelType int) string {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
@@ -604,9 +606,60 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Codex OAuth key validation (optional, only when JSON object is provided)
|
||||
if channel.Type == constant.ChannelTypeCodex {
|
||||
trimmedKey := strings.TrimSpace(channel.Key)
|
||||
if isAdd || trimmedKey != "" {
|
||||
if !strings.HasPrefix(trimmedKey, "{") {
|
||||
return fmt.Errorf("Codex key must be a valid JSON object")
|
||||
}
|
||||
var keyMap map[string]any
|
||||
if err := common.Unmarshal([]byte(trimmedKey), &keyMap); err != nil {
|
||||
return fmt.Errorf("Codex key must be a valid JSON object")
|
||||
}
|
||||
if v, ok := keyMap["access_token"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" {
|
||||
return fmt.Errorf("Codex key JSON must include access_token")
|
||||
}
|
||||
if v, ok := keyMap["account_id"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" {
|
||||
return fmt.Errorf("Codex key JSON must include account_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RefreshCodexChannelCredential(c *gin.Context) {
|
||||
channelId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, fmt.Errorf("invalid channel id: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "refreshed",
|
||||
"data": gin.H{
|
||||
"expires_at": oauthKey.Expired,
|
||||
"last_refresh": oauthKey.LastRefresh,
|
||||
"account_id": oauthKey.AccountID,
|
||||
"email": oauthKey.Email,
|
||||
"channel_id": ch.Id,
|
||||
"channel_type": ch.Type,
|
||||
"channel_name": ch.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type AddChannelRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
|
||||
|
||||
243
controller/codex_oauth.go
Normal file
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"`
|
||||
Err string `json:"err"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Header struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"header"`
|
||||
@@ -79,6 +80,9 @@ func (e GeneralErrorResponse) ToMessage() string {
|
||||
if e.ErrorMsg != "" {
|
||||
return e.ErrorMsg
|
||||
}
|
||||
if e.Detail != "" {
|
||||
return e.Detail
|
||||
}
|
||||
if e.Header.Message != "" {
|
||||
return e.Header.Message
|
||||
}
|
||||
|
||||
@@ -372,6 +372,10 @@ type ResponsesStreamResponse struct {
|
||||
Response *OpenAIResponsesResponse `json:"response,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Item *ResponsesOutput `json:"item,omitempty"`
|
||||
// - response.function_call_arguments.delta
|
||||
// - response.function_call_arguments.done
|
||||
OutputIndex *int `json:"output_index,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
}
|
||||
|
||||
// GetOpenAIError 从动态错误类型中提取OpenAIError结构
|
||||
|
||||
3
main.go
3
main.go
@@ -102,6 +102,9 @@ func main() {
|
||||
|
||||
go controller.AutomaticallyTestChannels()
|
||||
|
||||
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
|
||||
service.StartCodexCredentialAutoRefreshTask()
|
||||
|
||||
if common.IsMasterNode && constant.UpdateTask {
|
||||
gopool.Go(func() {
|
||||
controller.UpdateMidjourneyTaskBulk()
|
||||
|
||||
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)
|
||||
|
||||
var responsesResp dto.OpenAIResponsesResponse
|
||||
const maxResponseBodyBytes = 10 << 20 // 10MB
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes+1))
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
if int64(len(body)) > maxResponseBodyBytes {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &responsesResp); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
@@ -77,12 +73,99 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
|
||||
var (
|
||||
usage = &dto.Usage{}
|
||||
textBuilder strings.Builder
|
||||
outputText strings.Builder
|
||||
usageText strings.Builder
|
||||
sentStart bool
|
||||
sentStop bool
|
||||
sawToolCall bool
|
||||
streamErr *types.NewAPIError
|
||||
)
|
||||
|
||||
toolCallIndexByID := make(map[string]int)
|
||||
toolCallNameByID := make(map[string]string)
|
||||
toolCallArgsByID := make(map[string]string)
|
||||
toolCallNameSent := make(map[string]bool)
|
||||
toolCallCanonicalIDByItemID := make(map[string]string)
|
||||
|
||||
sendStartIfNeeded := func() bool {
|
||||
if sentStart {
|
||||
return true
|
||||
}
|
||||
if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
sentStart = true
|
||||
return true
|
||||
}
|
||||
|
||||
sendToolCallDelta := func(callID string, name string, argsDelta string) bool {
|
||||
if callID == "" {
|
||||
return true
|
||||
}
|
||||
if outputText.Len() > 0 {
|
||||
// Prefer streaming assistant text over tool calls to match non-stream behavior.
|
||||
return true
|
||||
}
|
||||
if !sendStartIfNeeded() {
|
||||
return false
|
||||
}
|
||||
|
||||
idx, ok := toolCallIndexByID[callID]
|
||||
if !ok {
|
||||
idx = len(toolCallIndexByID)
|
||||
toolCallIndexByID[callID] = idx
|
||||
}
|
||||
if name != "" {
|
||||
toolCallNameByID[callID] = name
|
||||
}
|
||||
if toolCallNameByID[callID] != "" {
|
||||
name = toolCallNameByID[callID]
|
||||
}
|
||||
|
||||
tool := dto.ToolCallResponse{
|
||||
ID: callID,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Arguments: argsDelta,
|
||||
},
|
||||
}
|
||||
tool.SetIndex(idx)
|
||||
if name != "" && !toolCallNameSent[callID] {
|
||||
tool.Function.Name = name
|
||||
toolCallNameSent[callID] = true
|
||||
}
|
||||
|
||||
chunk := &dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: createAt,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
ToolCalls: []dto.ToolCallResponse{tool},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := helper.ObjectData(c, chunk); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
sawToolCall = true
|
||||
|
||||
// Include tool call data in the local builder for fallback token estimation.
|
||||
if tool.Function.Name != "" {
|
||||
usageText.WriteString(tool.Function.Name)
|
||||
}
|
||||
if argsDelta != "" {
|
||||
usageText.WriteString(argsDelta)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
if streamErr != nil {
|
||||
return false
|
||||
@@ -106,16 +189,13 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
}
|
||||
|
||||
case "response.output_text.delta":
|
||||
if !sentStart {
|
||||
if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
sentStart = true
|
||||
if !sendStartIfNeeded() {
|
||||
return false
|
||||
}
|
||||
|
||||
if streamResp.Delta != "" {
|
||||
textBuilder.WriteString(streamResp.Delta)
|
||||
outputText.WriteString(streamResp.Delta)
|
||||
usageText.WriteString(streamResp.Delta)
|
||||
delta := streamResp.Delta
|
||||
chunk := &dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
@@ -137,6 +217,59 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
}
|
||||
}
|
||||
|
||||
case "response.output_item.added", "response.output_item.done":
|
||||
if streamResp.Item == nil {
|
||||
break
|
||||
}
|
||||
if streamResp.Item.Type != "function_call" {
|
||||
break
|
||||
}
|
||||
|
||||
itemID := strings.TrimSpace(streamResp.Item.ID)
|
||||
callID := strings.TrimSpace(streamResp.Item.CallId)
|
||||
if callID == "" {
|
||||
callID = itemID
|
||||
}
|
||||
if itemID != "" && callID != "" {
|
||||
toolCallCanonicalIDByItemID[itemID] = callID
|
||||
}
|
||||
name := strings.TrimSpace(streamResp.Item.Name)
|
||||
if name != "" {
|
||||
toolCallNameByID[callID] = name
|
||||
}
|
||||
|
||||
newArgs := streamResp.Item.Arguments
|
||||
prevArgs := toolCallArgsByID[callID]
|
||||
argsDelta := ""
|
||||
if newArgs != "" {
|
||||
if strings.HasPrefix(newArgs, prevArgs) {
|
||||
argsDelta = newArgs[len(prevArgs):]
|
||||
} else {
|
||||
argsDelta = newArgs
|
||||
}
|
||||
toolCallArgsByID[callID] = newArgs
|
||||
}
|
||||
|
||||
if !sendToolCallDelta(callID, name, argsDelta) {
|
||||
return false
|
||||
}
|
||||
|
||||
case "response.function_call_arguments.delta":
|
||||
itemID := strings.TrimSpace(streamResp.ItemID)
|
||||
callID := toolCallCanonicalIDByItemID[itemID]
|
||||
if callID == "" {
|
||||
callID = itemID
|
||||
}
|
||||
if callID == "" {
|
||||
break
|
||||
}
|
||||
toolCallArgsByID[callID] += streamResp.Delta
|
||||
if !sendToolCallDelta(callID, "", streamResp.Delta) {
|
||||
return false
|
||||
}
|
||||
|
||||
case "response.function_call_arguments.done":
|
||||
|
||||
case "response.completed":
|
||||
if streamResp.Response != nil {
|
||||
if streamResp.Response.Model != "" {
|
||||
@@ -170,15 +303,15 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
}
|
||||
}
|
||||
|
||||
if !sentStart {
|
||||
if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
sentStart = true
|
||||
if !sendStartIfNeeded() {
|
||||
return false
|
||||
}
|
||||
if !sentStop {
|
||||
stop := helper.GenerateStopResponse(responseId, createAt, model, "stop")
|
||||
finishReason := "stop"
|
||||
if sawToolCall && outputText.Len() == 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
|
||||
if err := helper.ObjectData(c, stop); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
@@ -196,8 +329,6 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
|
||||
case "response.output_item.added", "response.output_item.done":
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -209,7 +340,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
}
|
||||
|
||||
if usage.TotalTokens == 0 {
|
||||
usage = service.ResponseText2Usage(c, textBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
usage = service.ResponseText2Usage(c, usageText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
}
|
||||
|
||||
if !sentStart {
|
||||
@@ -218,7 +349,11 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
}
|
||||
}
|
||||
if !sentStop {
|
||||
stop := helper.GenerateStopResponse(responseId, createAt, model, "stop")
|
||||
finishReason := "stop"
|
||||
if sawToolCall && outputText.Len() == 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
|
||||
if err := helper.ObjectData(c, stop); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -274,6 +274,7 @@ var streamSupportedChannels = map[int]bool{
|
||||
constant.ChannelTypeZhipu_v4: true,
|
||||
constant.ChannelTypeAli: true,
|
||||
constant.ChannelTypeSubmodel: true,
|
||||
constant.ChannelTypeCodex: true,
|
||||
}
|
||||
|
||||
func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
|
||||
|
||||
@@ -75,10 +75,11 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
}
|
||||
adaptor.Init(info)
|
||||
|
||||
passThroughGlobal := model_setting.GetGlobalSettings().PassThroughRequestEnabled
|
||||
if info.RelayMode == relayconstant.RelayModeChatCompletions &&
|
||||
!model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
|
||||
!passThroughGlobal &&
|
||||
!info.ChannelSetting.PassThroughBodyEnabled &&
|
||||
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName) {
|
||||
shouldChatCompletionsViaResponses(info) {
|
||||
applySystemPromptIfNeeded(c, info, request)
|
||||
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
|
||||
if newApiErr != nil {
|
||||
@@ -98,7 +99,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
|
||||
var requestBody io.Reader
|
||||
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
|
||||
if passThroughGlobal || info.ChannelSetting.PassThroughBodyEnabled {
|
||||
body, err := common.GetRequestBody(c)
|
||||
if err != nil {
|
||||
return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
||||
@@ -216,6 +217,16 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldChatCompletionsViaResponses(info *relaycommon.RelayInfo) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
if info.RelayMode != relayconstant.RelayModeChatCompletions {
|
||||
return false
|
||||
}
|
||||
return service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName)
|
||||
}
|
||||
|
||||
func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
|
||||
if usage == nil {
|
||||
usage = &dto.Usage{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay/channel/baidu_v2"
|
||||
"github.com/QuantumNous/new-api/relay/channel/claude"
|
||||
"github.com/QuantumNous/new-api/relay/channel/cloudflare"
|
||||
"github.com/QuantumNous/new-api/relay/channel/codex"
|
||||
"github.com/QuantumNous/new-api/relay/channel/cohere"
|
||||
"github.com/QuantumNous/new-api/relay/channel/coze"
|
||||
"github.com/QuantumNous/new-api/relay/channel/deepseek"
|
||||
@@ -117,6 +118,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
|
||||
return &minimax.Adaptor{}
|
||||
case constant.APITypeReplicate:
|
||||
return &replicate.Adaptor{}
|
||||
case constant.APITypeCodex:
|
||||
return &codex.Adaptor{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -156,6 +156,12 @@ func SetApiRouter(router *gin.Engine) {
|
||||
channelRoute.POST("/fix", controller.FixChannelsAbilities)
|
||||
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
|
||||
channelRoute.POST("/fetch_models", controller.FetchModels)
|
||||
channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth)
|
||||
channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth)
|
||||
channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel)
|
||||
channelRoute.POST("/:id/codex/oauth/complete", controller.CompleteCodexOAuthForChannel)
|
||||
channelRoute.POST("/:id/codex/refresh", controller.RefreshCodexChannelCredential)
|
||||
channelRoute.GET("/:id/codex/usage", controller.GetCodexChannelUsage)
|
||||
channelRoute.POST("/ollama/pull", controller.OllamaPullModel)
|
||||
channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream)
|
||||
channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if role == "tool" || role == "function" {
|
||||
callID := strings.TrimSpace(msg.ToolCallId)
|
||||
|
||||
var output any
|
||||
if msg.Content == nil {
|
||||
output = ""
|
||||
} else if msg.IsStringContent() {
|
||||
output = msg.StringContent()
|
||||
} else {
|
||||
if b, err := common.Marshal(msg.Content); err == nil {
|
||||
output = string(b)
|
||||
} else {
|
||||
output = fmt.Sprintf("%v", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
if callID == "" {
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"role": "user",
|
||||
"content": fmt.Sprintf("[tool_output_missing_call_id] %v", output),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": callID,
|
||||
"output": output,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer mapping system/developer messages into `instructions`.
|
||||
if role == "system" || role == "developer" {
|
||||
if msg.Content == nil {
|
||||
@@ -88,12 +120,54 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
|
||||
if msg.Content == nil {
|
||||
item["content"] = ""
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.IsStringContent() {
|
||||
item["content"] = msg.StringContent()
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -127,7 +201,6 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
|
||||
"video_url": part.VideoUrl,
|
||||
})
|
||||
default:
|
||||
// Best-effort: keep unknown parts as-is to avoid silently dropping context.
|
||||
contentParts = append(contentParts, map[string]any{
|
||||
"type": part.Type,
|
||||
})
|
||||
@@ -135,6 +208,27 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
|
||||
}
|
||||
item["content"] = contentParts
|
||||
inputItems = append(inputItems, item)
|
||||
|
||||
if role == "assistant" {
|
||||
for _, tc := range msg.ParseToolCalls() {
|
||||
if strings.TrimSpace(tc.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Type != "" && tc.Type != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
inputItems = append(inputItems, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": tc.ID,
|
||||
"name": name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inputRaw, err := common.Marshal(inputItems)
|
||||
|
||||
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';
|
||||
import ModelSelectModal from './ModelSelectModal';
|
||||
import OllamaModelModal from './OllamaModelModal';
|
||||
import CodexOAuthModal from './CodexOAuthModal';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
||||
@@ -114,6 +115,8 @@ function type2secretPrompt(type) {
|
||||
return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey';
|
||||
case 51:
|
||||
return '按照如下格式输入: AccessKey|SecretAccessKey';
|
||||
case 57:
|
||||
return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id)';
|
||||
default:
|
||||
return '请输入渠道对应的鉴权密钥';
|
||||
}
|
||||
@@ -212,6 +215,9 @@ const EditChannelModal = (props) => {
|
||||
}, [inputs.model_mapping]);
|
||||
const [isIonetChannel, setIsIonetChannel] = useState(false);
|
||||
const [ionetMetadata, setIonetMetadata] = useState(null);
|
||||
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
|
||||
const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
|
||||
useState(false);
|
||||
|
||||
// 密钥显示状态
|
||||
const [keyDisplayState, setKeyDisplayState] = useState({
|
||||
@@ -499,6 +505,18 @@ const EditChannelModal = (props) => {
|
||||
|
||||
// 重置手动输入模式状态
|
||||
setUseManualInput(false);
|
||||
|
||||
if (value === 57) {
|
||||
setBatch(false);
|
||||
setMultiToSingle(false);
|
||||
setMultiKeyMode('random');
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('vertex_files', []);
|
||||
}
|
||||
setInputs((prev) => ({ ...prev, vertex_files: [] }));
|
||||
}
|
||||
}
|
||||
//setAutoBan
|
||||
};
|
||||
@@ -822,6 +840,32 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodexOAuthGenerated = (key) => {
|
||||
handleInputChange('key', key);
|
||||
formatJsonField('key');
|
||||
};
|
||||
|
||||
const handleRefreshCodexCredential = async () => {
|
||||
if (!isEdit) return;
|
||||
|
||||
setCodexCredentialRefreshing(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
`/api/channel/${channelId}/codex/refresh`,
|
||||
{},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
if (!res?.data?.success) {
|
||||
throw new Error(res?.data?.message || 'Failed to refresh credential');
|
||||
}
|
||||
showSuccess(t('凭证已刷新'));
|
||||
} catch (error) {
|
||||
showError(error.message || t('刷新失败'));
|
||||
} finally {
|
||||
setCodexCredentialRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inputs.type !== 45) {
|
||||
doubaoApiClickCountRef.current = 0;
|
||||
@@ -1070,6 +1114,47 @@ const EditChannelModal = (props) => {
|
||||
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
|
||||
let localInputs = { ...formValues };
|
||||
|
||||
if (localInputs.type === 57) {
|
||||
if (batch) {
|
||||
showInfo(t('Codex 渠道不支持批量创建'));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawKey = (localInputs.key || '').trim();
|
||||
if (!isEdit && rawKey === '') {
|
||||
showInfo(t('请输入密钥!'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawKey !== '') {
|
||||
if (!verifyJSON(rawKey)) {
|
||||
showInfo(t('密钥必须是合法的 JSON 格式!'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawKey);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
showInfo(t('密钥必须是 JSON 对象'));
|
||||
return;
|
||||
}
|
||||
const accessToken = String(parsed.access_token || '').trim();
|
||||
const accountId = String(parsed.account_id || '').trim();
|
||||
if (!accessToken) {
|
||||
showInfo(t('密钥 JSON 必须包含 access_token'));
|
||||
return;
|
||||
}
|
||||
if (!accountId) {
|
||||
showInfo(t('密钥 JSON 必须包含 account_id'));
|
||||
return;
|
||||
}
|
||||
localInputs.key = JSON.stringify(parsed);
|
||||
} catch (error) {
|
||||
showInfo(t('密钥必须是合法的 JSON 格式!'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (localInputs.type === 41) {
|
||||
const keyType = localInputs.vertex_key_type || 'json';
|
||||
if (keyType === 'api_key') {
|
||||
@@ -1401,7 +1486,7 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const batchAllowed = !isEdit || isMultiKeyChannel;
|
||||
const batchAllowed = (!isEdit || isMultiKeyChannel) && inputs.type !== 57;
|
||||
const batchExtra = batchAllowed ? (
|
||||
<Space>
|
||||
{!isEdit && (
|
||||
@@ -1884,8 +1969,94 @@ const EditChannelModal = (props) => {
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
{inputs.type === 57 ? (
|
||||
<>
|
||||
<Form.TextArea
|
||||
field='key'
|
||||
label={
|
||||
isEdit
|
||||
? t('密钥(编辑模式下,保存的密钥不会显示)')
|
||||
: t('密钥')
|
||||
}
|
||||
placeholder={t(
|
||||
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
|
||||
)}
|
||||
rules={
|
||||
isEdit
|
||||
? []
|
||||
: [{ required: true, message: t('请输入密钥') }]
|
||||
}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
disabled={isIonetLocked}
|
||||
extraText={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Space wrap spacing='tight'>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() =>
|
||||
setCodexOAuthModalVisible(true)
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('Codex 授权')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
loading={codexCredentialRefreshing}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('刷新凭证')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => formatJsonField('key')}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('格式化')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
/>
|
||||
|
||||
<CodexOAuthModal
|
||||
visible={codexOAuthModalVisible}
|
||||
onCancel={() => setCodexOAuthModalVisible(false)}
|
||||
onSuccess={handleCodexOAuthGenerated}
|
||||
/>
|
||||
</>
|
||||
) : inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
|
||||
@@ -184,6 +184,11 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'blue',
|
||||
label: 'Replicate',
|
||||
},
|
||||
{
|
||||
value: 57,
|
||||
color: 'blue',
|
||||
label: 'Codex (OpenAI OAuth)',
|
||||
},
|
||||
];
|
||||
|
||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
@@ -301,6 +301,7 @@ export function getChannelIcon(channelType) {
|
||||
switch (channelType) {
|
||||
case 1: // OpenAI
|
||||
case 3: // Azure OpenAI
|
||||
case 57: // Codex
|
||||
return <OpenAI size={iconSize} />;
|
||||
case 2: // Midjourney Proxy
|
||||
case 5: // Midjourney Proxy Plus
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { useIsMobile } from '../common/useIsMobile';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import { Modal, Button } from '@douyinfe/semi-ui';
|
||||
import { openCodexUsageModal } from '../../components/table/channels/modals/CodexUsageModal';
|
||||
|
||||
export const useChannelsData = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -745,6 +746,32 @@ export const useChannelsData = () => {
|
||||
};
|
||||
|
||||
const updateChannelBalance = async (record) => {
|
||||
if (record?.type === 57) {
|
||||
try {
|
||||
const res = await API.get(`/api/channel/${record.id}/codex/usage`, {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
if (!res?.data?.success) {
|
||||
console.error('Codex usage fetch failed:', res?.data?.message);
|
||||
showError(t('获取用量失败'));
|
||||
}
|
||||
openCodexUsageModal({
|
||||
t,
|
||||
record,
|
||||
payload: res?.data,
|
||||
onCopy: async (text) => {
|
||||
const ok = await copy(text);
|
||||
if (ok) showSuccess(t('已复制'));
|
||||
else showError(t('复制失败'));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Codex usage fetch error:', error);
|
||||
showError(t('获取用量失败'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await API.get(`/api/channel/update_balance/${record.id}/`);
|
||||
const { success, message, balance } = res.data;
|
||||
if (success) {
|
||||
|
||||
Reference in New Issue
Block a user