mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-01 23:31:15 +00:00
Compare commits
49 Commits
refactor/a
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75fa0398b3 | ||
|
|
ff9ed2af96 | ||
|
|
d1f2b707e3 | ||
|
|
c3291e407a | ||
|
|
d668788be2 | ||
|
|
985189af23 | ||
|
|
5ed997905c | ||
|
|
982dc5c56a | ||
|
|
4a4cf0a0df | ||
|
|
c5365e4b43 | ||
|
|
0da0d80647 | ||
|
|
aa9e0fe7a8 | ||
|
|
79e1daff5a | ||
|
|
4c7e65cb24 | ||
|
|
6d03fc828d | ||
|
|
af31935102 | ||
|
|
d2553564e0 | ||
|
|
a7c35cd61e | ||
|
|
98de082804 | ||
|
|
0d0f7473d4 | ||
|
|
532691b06b | ||
|
|
0835e15091 | ||
|
|
80c213072c | ||
|
|
2f4d38fefd | ||
|
|
9a5f8222bd | ||
|
|
016812baa6 | ||
|
|
d0b35ed60b | ||
|
|
4b058b4a1d | ||
|
|
722b77dc31 | ||
|
|
77838100a6 | ||
|
|
a01a77fc6f | ||
|
|
3b87d31191 | ||
|
|
3b6af5dca3 | ||
|
|
af2831ce31 | ||
|
|
ee414e10c9 | ||
|
|
3523947aba | ||
|
|
c4c4e5eda6 | ||
|
|
4831bb7b5b | ||
|
|
f4dded51ab | ||
|
|
13ada6484a | ||
|
|
902661df3f | ||
|
|
a546871a80 | ||
|
|
2c5af0df36 | ||
|
|
1770a08504 | ||
|
|
6004314c88 | ||
|
|
20c9002fde | ||
|
|
721d0a41fb | ||
|
|
4360393dc1 | ||
|
|
e5d47daf26 |
6
.gitattributes
vendored
6
.gitattributes
vendored
@@ -34,5 +34,9 @@
|
||||
# ============================================
|
||||
# GitHub Linguist - Language Detection
|
||||
# ============================================
|
||||
# Mark web frontend as vendored so GitHub recognizes this as a Go project
|
||||
electron/** linguist-vendored
|
||||
web/** linguist-vendored
|
||||
|
||||
# Un-vendor core frontend source to keep JavaScript visible in language stats
|
||||
web/src/components/** linguist-vendored=false
|
||||
web/src/pages/** linguist-vendored=false
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
<a href="https://trendshift.io/repositories/20180" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/20180" alt="QuantumNous%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://hellogithub.com/repository/QuantumNous/new-api" target="_blank">
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
<a href="https://trendshift.io/repositories/20180" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/20180" alt="QuantumNous%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://hellogithub.com/repository/QuantumNous/new-api" target="_blank">
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
<a href="https://trendshift.io/repositories/20180" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/20180" alt="QuantumNous%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://hellogithub.com/repository/QuantumNous/new-api" target="_blank">
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
<a href="https://trendshift.io/repositories/20180" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/20180" alt="QuantumNous%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://hellogithub.com/repository/QuantumNous/new-api" target="_blank">
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
<a href="https://trendshift.io/repositories/20180" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/20180" alt="QuantumNous%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://hellogithub.com/repository/QuantumNous/new-api" target="_blank">
|
||||
|
||||
@@ -303,7 +303,13 @@ func parseFormData(data []byte, v any) error {
|
||||
}
|
||||
|
||||
func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
var contentType string
|
||||
if saved, ok := c.Get("_original_multipart_ct"); ok {
|
||||
contentType = saved.(string)
|
||||
} else {
|
||||
contentType = c.Request.Header.Get("Content-Type")
|
||||
c.Set("_original_multipart_ct", contentType)
|
||||
}
|
||||
boundary, err := parseBoundary(contentType)
|
||||
if err != nil {
|
||||
if errors.Is(err, errBoundaryNotFound) {
|
||||
|
||||
@@ -145,6 +145,8 @@ func initConstantEnv() {
|
||||
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
|
||||
// 任务轮询时查询的最大数量
|
||||
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
|
||||
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
|
||||
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 1440)
|
||||
|
||||
soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
|
||||
if soraPatchStr != "" {
|
||||
|
||||
@@ -16,6 +16,7 @@ var NotificationLimitDurationMinute int
|
||||
var GenerateDefaultToken bool
|
||||
var ErrorLogEnabled bool
|
||||
var TaskQueryLimit int
|
||||
var TaskTimeoutMinutes int
|
||||
|
||||
// temporary variable for sora patch, will be removed in future
|
||||
var TaskPricePatches []string
|
||||
|
||||
@@ -145,6 +145,7 @@ func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) {
|
||||
return
|
||||
}
|
||||
|
||||
channelProxy := ""
|
||||
if channelID > 0 {
|
||||
ch, err := model.GetChannelById(channelID, false)
|
||||
if err != nil {
|
||||
@@ -159,6 +160,7 @@ func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"})
|
||||
return
|
||||
}
|
||||
channelProxy = ch.GetSetting().Proxy
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
@@ -176,7 +178,7 @@ func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokenRes, err := service.ExchangeCodexAuthorizationCode(ctx, code, verifier)
|
||||
tokenRes, err := service.ExchangeCodexAuthorizationCodeWithProxy(ctx, code, verifier, channelProxy)
|
||||
if err != nil {
|
||||
common.SysError("failed to exchange codex authorization code: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "授权码交换失败,请重试"})
|
||||
|
||||
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -80,7 +79,7 @@ func GetCodexChannelUsage(c *gin.Context) {
|
||||
refreshCtx, refreshCancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer refreshCancel()
|
||||
|
||||
res, refreshErr := service.RefreshCodexOAuthToken(refreshCtx, oauthKey.RefreshToken)
|
||||
res, refreshErr := service.RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, ch.GetSetting().Proxy)
|
||||
if refreshErr == nil {
|
||||
oauthKey.AccessToken = res.AccessToken
|
||||
oauthKey.RefreshToken = res.RefreshToken
|
||||
@@ -109,7 +108,7 @@ func GetCodexChannelUsage(c *gin.Context) {
|
||||
}
|
||||
|
||||
var payload any
|
||||
if json.Unmarshal(body, &payload) != nil {
|
||||
if common.Unmarshal(body, &payload) != nil {
|
||||
payload = string(body)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ type CustomOAuthProviderResponse struct {
|
||||
AccessDeniedMessage string `json:"access_denied_message"`
|
||||
}
|
||||
|
||||
type UserOAuthBindingResponse struct {
|
||||
ProviderId int `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ProviderSlug string `json:"provider_slug"`
|
||||
ProviderIcon string `json:"provider_icon"`
|
||||
ProviderUserId string `json:"provider_user_id"`
|
||||
}
|
||||
|
||||
func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
|
||||
return &CustomOAuthProviderResponse{
|
||||
Id: p.Id,
|
||||
@@ -433,6 +441,30 @@ func DeleteCustomOAuthProvider(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func buildUserOAuthBindingsResponse(userId int) ([]UserOAuthBindingResponse, error) {
|
||||
bindings, err := model.GetUserOAuthBindingsByUserId(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := make([]UserOAuthBindingResponse, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
response = append(response, UserOAuthBindingResponse{
|
||||
ProviderId: binding.ProviderId,
|
||||
ProviderName: provider.Name,
|
||||
ProviderSlug: provider.Slug,
|
||||
ProviderIcon: provider.Icon,
|
||||
ProviderUserId: binding.ProviderUserId,
|
||||
})
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetUserOAuthBindings returns all OAuth bindings for the current user
|
||||
func GetUserOAuthBindings(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
@@ -441,34 +473,43 @@ func GetUserOAuthBindings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
bindings, err := model.GetUserOAuthBindingsByUserId(userId)
|
||||
response, err := buildUserOAuthBindingsResponse(userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build response with provider info
|
||||
type BindingResponse struct {
|
||||
ProviderId int `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ProviderSlug string `json:"provider_slug"`
|
||||
ProviderIcon string `json:"provider_icon"`
|
||||
ProviderUserId string `json:"provider_user_id"`
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": response,
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserOAuthBindingsByAdmin(c *gin.Context) {
|
||||
userIdStr := c.Param("id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]BindingResponse, 0)
|
||||
for _, binding := range bindings {
|
||||
provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
|
||||
if err != nil {
|
||||
continue // Skip if provider not found
|
||||
}
|
||||
response = append(response, BindingResponse{
|
||||
ProviderId: binding.ProviderId,
|
||||
ProviderName: provider.Name,
|
||||
ProviderSlug: provider.Slug,
|
||||
ProviderIcon: provider.Icon,
|
||||
ProviderUserId: binding.ProviderUserId,
|
||||
})
|
||||
targetUser, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := buildUserOAuthBindingsResponse(userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -503,3 +544,41 @@ func UnbindCustomOAuth(c *gin.Context) {
|
||||
"message": "解绑成功",
|
||||
})
|
||||
}
|
||||
|
||||
func UnbindCustomOAuthByAdmin(c *gin.Context) {
|
||||
userIdStr := c.Param("id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
targetUser, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
providerIdStr := c.Param("provider_id")
|
||||
providerId, err := strconv.Atoi(providerIdStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "invalid provider id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "success",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,13 +105,13 @@ func UpdateMidjourneyTaskBulk() {
|
||||
}
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err))
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error: %v", err))
|
||||
continue
|
||||
}
|
||||
var responseItems []dto.MidjourneyDto
|
||||
err = json.Unmarshal(responseBody, &responseItems)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Mjp Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
@@ -181,8 +181,18 @@ func UpdateMidjourneyTaskBulk() {
|
||||
if err != nil {
|
||||
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
|
||||
}
|
||||
logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, logger.LogQuota(task.Quota))
|
||||
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||||
UserId: task.UserId,
|
||||
LogType: model.LogTypeRefund,
|
||||
Content: "",
|
||||
ChannelId: task.ChannelId,
|
||||
ModelName: service.CovertMjpActionToModelName(task.Action),
|
||||
Quota: task.Quota,
|
||||
Other: map[string]interface{}{
|
||||
"task_id": task.MjId,
|
||||
"reason": "构图失败",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,16 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
||||
|
||||
// Set up new user
|
||||
user.Username = provider.GetProviderPrefix() + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
|
||||
if oauthUser.Username != "" {
|
||||
if exists, err := model.CheckUserExistOrDeleted(oauthUser.Username, ""); err == nil && !exists {
|
||||
// 防止索引退化
|
||||
if len(oauthUser.Username) <= model.UserNameMaxLength {
|
||||
user.Username = oauthUser.Username
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if oauthUser.DisplayName != "" {
|
||||
user.DisplayName = oauthUser.DisplayName
|
||||
} else if oauthUser.Username != "" {
|
||||
|
||||
@@ -614,7 +614,7 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError,
|
||||
}
|
||||
if taskErr.StatusCode/100 == 5 {
|
||||
// 超时不重试
|
||||
if taskErr.StatusCode == 504 || taskErr.StatusCode == 524 {
|
||||
if operation_setting.IsAlwaysSkipRetryStatusCode(taskErr.StatusCode) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -582,6 +582,44 @@ func UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func AdminClearUserBinding(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
|
||||
bindingType := strings.ToLower(strings.TrimSpace(c.Param("binding_type")))
|
||||
if bindingType == "" {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.ClearBinding(bindingType); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("admin cleared %s binding for user %s", bindingType, user.Username))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "success",
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateSelf(c *gin.Context) {
|
||||
var requestData map[string]interface{}
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
|
||||
|
||||
@@ -2,10 +2,12 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
@@ -94,6 +96,13 @@ func VideoProxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", apiKey)
|
||||
case constant.ChannelTypeVertexAi:
|
||||
videoURL, err = getVertexVideoURL(channel, task)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Vertex video URL for task %s: %s", taskID, err.Error()))
|
||||
videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to resolve Vertex video URL")
|
||||
return
|
||||
}
|
||||
case constant.ChannelTypeOpenAI, constant.ChannelTypeSora:
|
||||
videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.GetUpstreamTaskID())
|
||||
req.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
@@ -102,6 +111,21 @@ func VideoProxy(c *gin.Context) {
|
||||
videoURL = task.GetResultURL()
|
||||
}
|
||||
|
||||
videoURL = strings.TrimSpace(videoURL)
|
||||
if videoURL == "" {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL is empty for task %s", taskID))
|
||||
videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to fetch video content")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(videoURL, "data:") {
|
||||
if err := writeVideoDataURL(c, videoURL); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to decode video data URL for task %s: %s", taskID, err.Error()))
|
||||
videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to fetch video content")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
req.URL, err = url.Parse(videoURL)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
|
||||
@@ -136,3 +160,36 @@ func VideoProxy(c *gin.Context) {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to stream video content: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func writeVideoDataURL(c *gin.Context, dataURL string) error {
|
||||
parts := strings.SplitN(dataURL, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid data url")
|
||||
}
|
||||
|
||||
header := parts[0]
|
||||
payload := parts[1]
|
||||
if !strings.HasPrefix(header, "data:") || !strings.Contains(header, ";base64") {
|
||||
return fmt.Errorf("unsupported data url")
|
||||
}
|
||||
|
||||
mimeType := strings.TrimPrefix(header, "data:")
|
||||
mimeType = strings.TrimSuffix(mimeType, ";base64")
|
||||
if mimeType == "" {
|
||||
mimeType = "video/mp4"
|
||||
}
|
||||
|
||||
videoBytes, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
videoBytes, err = base64.RawStdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", mimeType)
|
||||
c.Writer.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, err = c.Writer.Write(videoBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -145,6 +145,141 @@ func extractGeminiVideoURLFromGeneratedSamples(gvr map[string]any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func getVertexVideoURL(channel *model.Channel, task *model.Task) (string, error) {
|
||||
if channel == nil || task == nil {
|
||||
return "", fmt.Errorf("invalid channel or task")
|
||||
}
|
||||
if url := strings.TrimSpace(task.GetResultURL()); url != "" && !isTaskProxyContentURL(url, task.TaskID) {
|
||||
return url, nil
|
||||
}
|
||||
if url := extractVertexVideoURLFromTaskData(task); url != "" {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
|
||||
adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
|
||||
if adaptor == nil {
|
||||
return "", fmt.Errorf("vertex task adaptor not found")
|
||||
}
|
||||
|
||||
key := getVertexTaskKey(channel, task)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("vertex key not available for task")
|
||||
}
|
||||
|
||||
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
|
||||
"task_id": task.GetUpstreamTaskID(),
|
||||
"action": task.Action,
|
||||
}, channel.GetSetting().Proxy)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch task failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read task response failed: %w", err)
|
||||
}
|
||||
|
||||
taskInfo, parseErr := adaptor.ParseTaskResult(body)
|
||||
if parseErr == nil && taskInfo != nil && strings.TrimSpace(taskInfo.Url) != "" {
|
||||
return taskInfo.Url, nil
|
||||
}
|
||||
if url := extractVertexVideoURLFromPayload(body); url != "" {
|
||||
return url, nil
|
||||
}
|
||||
if parseErr != nil {
|
||||
return "", fmt.Errorf("parse task result failed: %w", parseErr)
|
||||
}
|
||||
return "", fmt.Errorf("vertex video url not found")
|
||||
}
|
||||
|
||||
func isTaskProxyContentURL(url string, taskID string) bool {
|
||||
if strings.TrimSpace(url) == "" || strings.TrimSpace(taskID) == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(url, "/v1/videos/"+taskID+"/content")
|
||||
}
|
||||
|
||||
func getVertexTaskKey(channel *model.Channel, task *model.Task) string {
|
||||
if task != nil {
|
||||
if key := strings.TrimSpace(task.PrivateData.Key); key != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
if channel == nil {
|
||||
return ""
|
||||
}
|
||||
keys := channel.GetKeys()
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(channel.Key)
|
||||
}
|
||||
|
||||
func extractVertexVideoURLFromTaskData(task *model.Task) string {
|
||||
if task == nil || len(task.Data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return extractVertexVideoURLFromPayload(task.Data)
|
||||
}
|
||||
|
||||
func extractVertexVideoURLFromPayload(body []byte) string {
|
||||
var payload map[string]any
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
resp, ok := payload["response"].(map[string]any)
|
||||
if !ok || resp == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if videos, ok := resp["videos"].([]any); ok && len(videos) > 0 {
|
||||
if video, ok := videos[0].(map[string]any); ok && video != nil {
|
||||
if b64, _ := video["bytesBase64Encoded"].(string); strings.TrimSpace(b64) != "" {
|
||||
mime, _ := video["mimeType"].(string)
|
||||
enc, _ := video["encoding"].(string)
|
||||
return buildVideoDataURL(mime, enc, b64)
|
||||
}
|
||||
}
|
||||
}
|
||||
if b64, _ := resp["bytesBase64Encoded"].(string); strings.TrimSpace(b64) != "" {
|
||||
enc, _ := resp["encoding"].(string)
|
||||
return buildVideoDataURL("", enc, b64)
|
||||
}
|
||||
if video, _ := resp["video"].(string); strings.TrimSpace(video) != "" {
|
||||
if strings.HasPrefix(video, "data:") || strings.HasPrefix(video, "http://") || strings.HasPrefix(video, "https://") {
|
||||
return video
|
||||
}
|
||||
enc, _ := resp["encoding"].(string)
|
||||
return buildVideoDataURL("", enc, video)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildVideoDataURL(mimeType string, encoding string, base64Data string) string {
|
||||
mime := strings.TrimSpace(mimeType)
|
||||
if mime == "" {
|
||||
enc := strings.TrimSpace(encoding)
|
||||
if enc == "" {
|
||||
enc = "mp4"
|
||||
}
|
||||
if strings.Contains(enc, "/") {
|
||||
mime = enc
|
||||
} else {
|
||||
mime = "video/" + enc
|
||||
}
|
||||
}
|
||||
return "data:" + mime + ";base64," + base64Data
|
||||
}
|
||||
|
||||
func ensureAPIKey(uri, key string) string {
|
||||
if key == "" || uri == "" {
|
||||
return uri
|
||||
|
||||
@@ -24,14 +24,16 @@ const (
|
||||
)
|
||||
|
||||
type ChannelOtherSettings struct {
|
||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
|
||||
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
|
||||
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
|
||||
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
|
||||
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
|
||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
|
||||
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
|
||||
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
|
||||
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规)
|
||||
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
|
||||
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
|
||||
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
|
||||
|
||||
@@ -190,10 +190,13 @@ type ClaudeToolChoice struct {
|
||||
}
|
||||
|
||||
type ClaudeRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []ClaudeMessage `json:"messages,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []ClaudeMessage `json:"messages,omitempty"`
|
||||
// InferenceGeo controls Claude data residency region.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_inference_geo.
|
||||
InferenceGeo string `json:"inference_geo,omitempty"`
|
||||
MaxTokens uint `json:"max_tokens,omitempty"`
|
||||
MaxTokensToSample uint `json:"max_tokens_to_sample,omitempty"`
|
||||
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||
@@ -210,7 +213,8 @@ type ClaudeRequest struct {
|
||||
Thinking *Thinking `json:"thinking,omitempty"`
|
||||
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
|
||||
// ServiceTier specifies upstream service level and may affect billing.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -324,25 +324,26 @@ type GeminiChatTool struct {
|
||||
}
|
||||
|
||||
type GeminiChatGenerationConfig struct {
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"topP,omitempty"`
|
||||
TopK float64 `json:"topK,omitempty"`
|
||||
MaxOutputTokens uint `json:"maxOutputTokens,omitempty"`
|
||||
CandidateCount int `json:"candidateCount,omitempty"`
|
||||
StopSequences []string `json:"stopSequences,omitempty"`
|
||||
ResponseMimeType string `json:"responseMimeType,omitempty"`
|
||||
ResponseSchema any `json:"responseSchema,omitempty"`
|
||||
ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
|
||||
PresencePenalty *float32 `json:"presencePenalty,omitempty"`
|
||||
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"`
|
||||
ResponseLogprobs bool `json:"responseLogprobs,omitempty"`
|
||||
Logprobs *int32 `json:"logprobs,omitempty"`
|
||||
MediaResolution MediaResolution `json:"mediaResolution,omitempty"`
|
||||
Seed int64 `json:"seed,omitempty"`
|
||||
ResponseModalities []string `json:"responseModalities,omitempty"`
|
||||
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
|
||||
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
|
||||
ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"topP,omitempty"`
|
||||
TopK float64 `json:"topK,omitempty"`
|
||||
MaxOutputTokens uint `json:"maxOutputTokens,omitempty"`
|
||||
CandidateCount int `json:"candidateCount,omitempty"`
|
||||
StopSequences []string `json:"stopSequences,omitempty"`
|
||||
ResponseMimeType string `json:"responseMimeType,omitempty"`
|
||||
ResponseSchema any `json:"responseSchema,omitempty"`
|
||||
ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
|
||||
PresencePenalty *float32 `json:"presencePenalty,omitempty"`
|
||||
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"`
|
||||
ResponseLogprobs bool `json:"responseLogprobs,omitempty"`
|
||||
Logprobs *int32 `json:"logprobs,omitempty"`
|
||||
EnableEnhancedCivicAnswers *bool `json:"enableEnhancedCivicAnswers,omitempty"`
|
||||
MediaResolution MediaResolution `json:"mediaResolution,omitempty"`
|
||||
Seed int64 `json:"seed,omitempty"`
|
||||
ResponseModalities []string `json:"responseModalities,omitempty"`
|
||||
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
|
||||
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
|
||||
ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows GeminiChatGenerationConfig to accept both snake_case and camelCase fields.
|
||||
@@ -350,22 +351,23 @@ func (c *GeminiChatGenerationConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias GeminiChatGenerationConfig
|
||||
var aux struct {
|
||||
Alias
|
||||
TopPSnake float64 `json:"top_p,omitempty"`
|
||||
TopKSnake float64 `json:"top_k,omitempty"`
|
||||
MaxOutputTokensSnake uint `json:"max_output_tokens,omitempty"`
|
||||
CandidateCountSnake int `json:"candidate_count,omitempty"`
|
||||
StopSequencesSnake []string `json:"stop_sequences,omitempty"`
|
||||
ResponseMimeTypeSnake string `json:"response_mime_type,omitempty"`
|
||||
ResponseSchemaSnake any `json:"response_schema,omitempty"`
|
||||
ResponseJsonSchemaSnake json.RawMessage `json:"response_json_schema,omitempty"`
|
||||
PresencePenaltySnake *float32 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenaltySnake *float32 `json:"frequency_penalty,omitempty"`
|
||||
ResponseLogprobsSnake bool `json:"response_logprobs,omitempty"`
|
||||
MediaResolutionSnake MediaResolution `json:"media_resolution,omitempty"`
|
||||
ResponseModalitiesSnake []string `json:"response_modalities,omitempty"`
|
||||
ThinkingConfigSnake *GeminiThinkingConfig `json:"thinking_config,omitempty"`
|
||||
SpeechConfigSnake json.RawMessage `json:"speech_config,omitempty"`
|
||||
ImageConfigSnake json.RawMessage `json:"image_config,omitempty"`
|
||||
TopPSnake float64 `json:"top_p,omitempty"`
|
||||
TopKSnake float64 `json:"top_k,omitempty"`
|
||||
MaxOutputTokensSnake uint `json:"max_output_tokens,omitempty"`
|
||||
CandidateCountSnake int `json:"candidate_count,omitempty"`
|
||||
StopSequencesSnake []string `json:"stop_sequences,omitempty"`
|
||||
ResponseMimeTypeSnake string `json:"response_mime_type,omitempty"`
|
||||
ResponseSchemaSnake any `json:"response_schema,omitempty"`
|
||||
ResponseJsonSchemaSnake json.RawMessage `json:"response_json_schema,omitempty"`
|
||||
PresencePenaltySnake *float32 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenaltySnake *float32 `json:"frequency_penalty,omitempty"`
|
||||
ResponseLogprobsSnake bool `json:"response_logprobs,omitempty"`
|
||||
EnableEnhancedCivicAnswersSnake *bool `json:"enable_enhanced_civic_answers,omitempty"`
|
||||
MediaResolutionSnake MediaResolution `json:"media_resolution,omitempty"`
|
||||
ResponseModalitiesSnake []string `json:"response_modalities,omitempty"`
|
||||
ThinkingConfigSnake *GeminiThinkingConfig `json:"thinking_config,omitempty"`
|
||||
SpeechConfigSnake json.RawMessage `json:"speech_config,omitempty"`
|
||||
ImageConfigSnake json.RawMessage `json:"image_config,omitempty"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(data, &aux); err != nil {
|
||||
@@ -408,6 +410,9 @@ func (c *GeminiChatGenerationConfig) UnmarshalJSON(data []byte) error {
|
||||
if aux.ResponseLogprobsSnake {
|
||||
c.ResponseLogprobs = aux.ResponseLogprobsSnake
|
||||
}
|
||||
if aux.EnableEnhancedCivicAnswersSnake != nil {
|
||||
c.EnableEnhancedCivicAnswers = aux.EnableEnhancedCivicAnswersSnake
|
||||
}
|
||||
if aux.MediaResolutionSnake != "" {
|
||||
c.MediaResolution = aux.MediaResolutionSnake
|
||||
}
|
||||
@@ -453,12 +458,14 @@ type GeminiChatResponse struct {
|
||||
}
|
||||
|
||||
type GeminiUsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
CachedContentTokenCount int `json:"cachedContentTokenCount"`
|
||||
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
ToolUsePromptTokenCount int `json:"toolUsePromptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
CachedContentTokenCount int `json:"cachedContentTokenCount"`
|
||||
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
||||
ToolUsePromptTokensDetails []GeminiPromptTokensDetails `json:"toolUsePromptTokensDetails"`
|
||||
}
|
||||
|
||||
type GeminiPromptTokensDetails struct {
|
||||
|
||||
@@ -54,18 +54,22 @@ type GeneralOpenAIRequest struct {
|
||||
ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
Tools []ToolCallRequest `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
FunctionCall json.RawMessage `json:"function_call,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
LogProbs bool `json:"logprobs,omitempty"`
|
||||
TopLogProbs int `json:"top_logprobs,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
Modalities json.RawMessage `json:"modalities,omitempty"`
|
||||
Audio json.RawMessage `json:"audio,omitempty"`
|
||||
// ServiceTier specifies upstream service level and may affect billing.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
LogProbs bool `json:"logprobs,omitempty"`
|
||||
TopLogProbs int `json:"top_logprobs,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
Modalities json.RawMessage `json:"modalities,omitempty"`
|
||||
Audio json.RawMessage `json:"audio,omitempty"`
|
||||
// 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户
|
||||
// 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私
|
||||
// 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤,可通过 allow_safety_identifier 开启
|
||||
SafetyIdentifier string `json:"safety_identifier,omitempty"`
|
||||
// Whether or not to store the output of this chat completion request for use in our model distillation or evals products.
|
||||
// 是否存储此次请求数据供 OpenAI 用于评估和优化产品
|
||||
// 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用
|
||||
// 注意:默认允许透传,可通过 disable_store 禁用;禁用后可能导致 Codex 无法正常使用
|
||||
Store json.RawMessage `json:"store,omitempty"`
|
||||
// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
@@ -261,6 +265,9 @@ type FunctionRequest struct {
|
||||
|
||||
type StreamOptions struct {
|
||||
IncludeUsage bool `json:"include_usage,omitempty"`
|
||||
// IncludeObfuscation is only for /v1/responses stream payload.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_include_obfuscation.
|
||||
IncludeObfuscation bool `json:"include_obfuscation,omitempty"`
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) GetMaxTokens() uint {
|
||||
@@ -799,30 +806,42 @@ type WebSearchOptions struct {
|
||||
|
||||
// https://platform.openai.com/docs/api-reference/responses/create
|
||||
type OpenAIResponsesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Include json.RawMessage `json:"include,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Include json.RawMessage `json:"include,omitempty"`
|
||||
// 在后台运行推理,暂时还不支持依赖的接口
|
||||
// Background json.RawMessage `json:"background,omitempty"`
|
||||
Conversation json.RawMessage `json:"conversation,omitempty"`
|
||||
ContextManagement json.RawMessage `json:"context_management,omitempty"`
|
||||
Instructions json.RawMessage `json:"instructions,omitempty"`
|
||||
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
|
||||
TopLogProbs *int `json:"top_logprobs,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
||||
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
// ServiceTier specifies upstream service level and may affect billing.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
// Store controls whether upstream may store request/response data.
|
||||
// This field is allowed by default and can be disabled via channel setting disable_store.
|
||||
Store json.RawMessage `json:"store,omitempty"`
|
||||
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
||||
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
// SafetyIdentifier carries client identity for policy abuse detection.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_safety_identifier.
|
||||
SafetyIdentifier string `json:"safety_identifier,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
// qwen
|
||||
EnableThinking json.RawMessage `json:"enable_thinking,omitempty"`
|
||||
// perplexity
|
||||
|
||||
@@ -7,14 +7,28 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const RouteTagKey = "route_tag"
|
||||
|
||||
func RouteTag(tag string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set(RouteTagKey, tag)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func SetUpLogger(server *gin.Engine) {
|
||||
server.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
var requestID string
|
||||
if param.Keys != nil {
|
||||
requestID = param.Keys[common.RequestIdKey].(string)
|
||||
requestID, _ = param.Keys[common.RequestIdKey].(string)
|
||||
}
|
||||
return fmt.Sprintf("[GIN] %s | %s | %3d | %13v | %15s | %7s %s\n",
|
||||
tag, _ := param.Keys[RouteTagKey].(string)
|
||||
if tag == "" {
|
||||
tag = "web"
|
||||
}
|
||||
return fmt.Sprintf("[GIN] %s | %s | %s | %3d | %13v | %15s | %7s %s\n",
|
||||
param.TimeStamp.Format("2006/01/02 - 15:04:05"),
|
||||
tag,
|
||||
requestID,
|
||||
param.StatusCode,
|
||||
param.Latency,
|
||||
|
||||
20
model/log.go
20
model/log.go
@@ -295,8 +295,24 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
Id int `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil {
|
||||
return logs, total, err
|
||||
if common.MemoryCacheEnabled {
|
||||
// Cache get channel
|
||||
for _, channelId := range channelIds.Items() {
|
||||
if cacheChannel, err := CacheGetChannel(channelId); err == nil {
|
||||
channels = append(channels, struct {
|
||||
Id int `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
}{
|
||||
Id: channelId,
|
||||
Name: cacheChannel.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Bulk query channels from DB
|
||||
if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil {
|
||||
return logs, total, err
|
||||
}
|
||||
}
|
||||
channelMap := make(map[int]string, len(channels))
|
||||
for _, channel := range channels {
|
||||
|
||||
@@ -173,7 +173,8 @@ func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo)
|
||||
properties := Properties{}
|
||||
privateData := TaskPrivateData{}
|
||||
if relayInfo != nil && relayInfo.ChannelMeta != nil {
|
||||
if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini {
|
||||
if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini ||
|
||||
relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeVertexAi {
|
||||
privateData.Key = relayInfo.ChannelMeta.ApiKey
|
||||
}
|
||||
if relayInfo.UpstreamModelName != "" {
|
||||
@@ -288,6 +289,20 @@ func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []*
|
||||
return tasks
|
||||
}
|
||||
|
||||
func GetTimedOutUnfinishedTasks(cutoffUnix int64, limit int) []*Task {
|
||||
var tasks []*Task
|
||||
err := DB.Where("progress != ?", "100%").
|
||||
Where("status NOT IN ?", []string{TaskStatusFailure, TaskStatusSuccess}).
|
||||
Where("submit_time < ?", cutoffUnix).
|
||||
Order("submit_time").
|
||||
Limit(limit).
|
||||
Find(&tasks).Error
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
func GetAllUnFinishSyncTasks(limit int) []*Task {
|
||||
var tasks []*Task
|
||||
var err error
|
||||
@@ -401,6 +416,11 @@ func (t *Task) UpdateWithStatus(fromStatus TaskStatus) (bool, error) {
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// TaskBulkUpdateByID performs an unconditional bulk UPDATE by primary key IDs.
|
||||
// WARNING: This function has NO CAS (Compare-And-Swap) guard — it will overwrite
|
||||
// any concurrent status changes. DO NOT use in billing/quota lifecycle flows
|
||||
// (e.g., timeout, success, failure transitions that trigger refunds or settlements).
|
||||
// For status transitions that involve billing, use Task.UpdateWithStatus() instead.
|
||||
func TaskBulkUpdateByID(ids []int64, params map[string]any) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,6 +16,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const UserNameMaxLength = 20
|
||||
|
||||
// User if you add sensitive fields, don't forget to clean them in setupLogin function.
|
||||
// Otherwise, the sensitive information will be saved on local storage in plain text!
|
||||
type User struct {
|
||||
@@ -536,6 +539,37 @@ func (user *User) Edit(updatePassword bool) error {
|
||||
return updateUserCache(*user)
|
||||
}
|
||||
|
||||
func (user *User) ClearBinding(bindingType string) error {
|
||||
if user.Id == 0 {
|
||||
return errors.New("user id is empty")
|
||||
}
|
||||
|
||||
bindingColumnMap := map[string]string{
|
||||
"email": "email",
|
||||
"github": "github_id",
|
||||
"discord": "discord_id",
|
||||
"oidc": "oidc_id",
|
||||
"wechat": "wechat_id",
|
||||
"telegram": "telegram_id",
|
||||
"linuxdo": "linux_do_id",
|
||||
}
|
||||
|
||||
column, ok := bindingColumnMap[bindingType]
|
||||
if !ok {
|
||||
return errors.New("invalid binding type")
|
||||
}
|
||||
|
||||
if err := DB.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := DB.Where("id = ?", user.Id).First(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return updateUserCache(*user)
|
||||
}
|
||||
|
||||
func (user *User) Delete() error {
|
||||
if user.Id == 0 {
|
||||
return errors.New("id 为空!")
|
||||
@@ -820,10 +854,17 @@ func GetUserSetting(id int, fromDB bool) (settingMap dto.UserSetting, err error)
|
||||
// Don't return error - fall through to DB
|
||||
}
|
||||
fromDB = true
|
||||
err = DB.Model(&User{}).Where("id = ?", id).Select("setting").Find(&setting).Error
|
||||
// can be nil setting
|
||||
var safeSetting sql.NullString
|
||||
err = DB.Model(&User{}).Where("id = ?", id).Select("setting").Find(&safeSetting).Error
|
||||
if err != nil {
|
||||
return settingMap, err
|
||||
}
|
||||
if safeSetting.Valid {
|
||||
setting = safeSetting.String
|
||||
} else {
|
||||
setting = ""
|
||||
}
|
||||
userBase := &UserBase{
|
||||
Setting: setting,
|
||||
}
|
||||
|
||||
@@ -61,8 +61,9 @@ var passthroughSkipHeaderNamesLower = map[string]struct{}{
|
||||
"cookie": {},
|
||||
|
||||
// Additional headers that should not be forwarded by name-matching passthrough rules.
|
||||
"host": {},
|
||||
"content-length": {},
|
||||
"host": {},
|
||||
"content-length": {},
|
||||
"accept-encoding": {},
|
||||
|
||||
// Do not passthrough credentials by wildcard/regex.
|
||||
"authorization": {},
|
||||
|
||||
@@ -79,3 +79,30 @@ func TestProcessHeaderOverride_NonTestKeepsClientHeaderPlaceholder(t *testing.T)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "trace-123", headers["X-Upstream-Trace"])
|
||||
}
|
||||
|
||||
func TestProcessHeaderOverride_PassthroughSkipsAcceptEncoding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
ctx.Request.Header.Set("X-Trace-Id", "trace-123")
|
||||
ctx.Request.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
IsChannelTest: false,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
HeadersOverride: map[string]any{
|
||||
"*": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
headers, err := processHeaderOverride(info, ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "trace-123", headers["X-Trace-Id"])
|
||||
|
||||
_, hasAcceptEncoding := headers["Accept-Encoding"]
|
||||
require.False(t, hasAcceptEncoding)
|
||||
}
|
||||
|
||||
@@ -42,22 +42,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
|
||||
}
|
||||
|
||||
// 计算使用量(基于 UsageMetadata)
|
||||
usage := dto.Usage{
|
||||
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount,
|
||||
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
|
||||
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
|
||||
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
|
||||
@@ -1032,6 +1032,46 @@ func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
|
||||
}
|
||||
}
|
||||
|
||||
func buildUsageFromGeminiMetadata(metadata dto.GeminiUsageMetadata, fallbackPromptTokens int) dto.Usage {
|
||||
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
|
||||
if promptTokens <= 0 && fallbackPromptTokens > 0 {
|
||||
promptTokens = fallbackPromptTokens
|
||||
}
|
||||
|
||||
usage := dto.Usage{
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
|
||||
TotalTokens: metadata.TotalTokenCount,
|
||||
}
|
||||
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
|
||||
|
||||
for _, detail := range metadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens += detail.TokenCount
|
||||
}
|
||||
}
|
||||
for _, detail := range metadata.ToolUsePromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens += detail.TokenCount
|
||||
}
|
||||
}
|
||||
|
||||
if usage.TotalTokens > 0 && usage.CompletionTokens <= 0 {
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
}
|
||||
|
||||
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
|
||||
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
|
||||
fullTextResponse := dto.OpenAITextResponse{
|
||||
Id: helper.GetResponseID(c),
|
||||
@@ -1272,18 +1312,8 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
|
||||
// 更新使用量统计
|
||||
if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
|
||||
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
|
||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
mappedUsage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
|
||||
*usage = mappedUsage
|
||||
}
|
||||
|
||||
return callback(data, &geminiResponse)
|
||||
@@ -1295,11 +1325,6 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
}
|
||||
}
|
||||
|
||||
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||
if usage.TotalTokens > 0 {
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
}
|
||||
|
||||
if usage.CompletionTokens <= 0 {
|
||||
if info.ReceivedResponseCount > 0 {
|
||||
usage = service.ResponseText2Usage(c, responseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
@@ -1416,21 +1441,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if len(geminiResponse.Candidates) == 0 {
|
||||
usage := dto.Usage{
|
||||
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
|
||||
}
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
if usage.PromptTokens <= 0 {
|
||||
usage.PromptTokens = info.GetEstimatePromptTokens()
|
||||
}
|
||||
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
|
||||
|
||||
var newAPIError *types.NewAPIError
|
||||
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
||||
@@ -1466,23 +1477,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
}
|
||||
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
|
||||
fullTextResponse.Model = info.UpstreamModelName
|
||||
usage := dto.Usage{
|
||||
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
|
||||
|
||||
fullTextResponse.Usage = usage
|
||||
|
||||
|
||||
333
relay/channel/gemini/relay_gemini_usage_test.go
Normal file
333
relay/channel/gemini/relay_gemini_usage_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeminiChatHandlerCompletionTokensExcludeToolUsePromptTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatGemini,
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
ToolUsePromptTokenCount: 18329,
|
||||
CandidatesTokenCount: 1089,
|
||||
ThoughtsTokenCount: 1120,
|
||||
TotalTokenCount: 20689,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiChatHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 18480, usage.PromptTokens)
|
||||
require.Equal(t, 2209, usage.CompletionTokens)
|
||||
require.Equal(t, 20689, usage.TotalTokens)
|
||||
require.Equal(t, 1120, usage.CompletionTokenDetails.ReasoningTokens)
|
||||
}
|
||||
|
||||
func TestGeminiStreamHandlerCompletionTokensExcludeToolUsePromptTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
|
||||
chunk := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "partial"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
ToolUsePromptTokenCount: 18329,
|
||||
CandidatesTokenCount: 1089,
|
||||
ThoughtsTokenCount: 1120,
|
||||
TotalTokenCount: 20689,
|
||||
},
|
||||
}
|
||||
|
||||
chunkData, err := common.Marshal(chunk)
|
||||
require.NoError(t, err)
|
||||
|
||||
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(streamBody)),
|
||||
}
|
||||
|
||||
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
|
||||
return true
|
||||
})
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 18480, usage.PromptTokens)
|
||||
require.Equal(t, 2209, usage.CompletionTokens)
|
||||
require.Equal(t, 20689, usage.TotalTokens)
|
||||
require.Equal(t, 1120, usage.CompletionTokenDetails.ReasoningTokens)
|
||||
}
|
||||
|
||||
func TestGeminiTextGenerationHandlerPromptTokensIncludeToolUsePromptTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3-flash-preview:generateContent", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 151,
|
||||
ToolUsePromptTokenCount: 18329,
|
||||
CandidatesTokenCount: 1089,
|
||||
ThoughtsTokenCount: 1120,
|
||||
TotalTokenCount: 20689,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiTextGenerationHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 18480, usage.PromptTokens)
|
||||
require.Equal(t, 2209, usage.CompletionTokens)
|
||||
require.Equal(t, 20689, usage.TotalTokens)
|
||||
require.Equal(t, 1120, usage.CompletionTokenDetails.ReasoningTokens)
|
||||
}
|
||||
|
||||
func TestGeminiChatHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatGemini,
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 0,
|
||||
ToolUsePromptTokenCount: 0,
|
||||
CandidatesTokenCount: 90,
|
||||
ThoughtsTokenCount: 10,
|
||||
TotalTokenCount: 110,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiChatHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 20, usage.PromptTokens)
|
||||
require.Equal(t, 100, usage.CompletionTokens)
|
||||
require.Equal(t, 110, usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestGeminiStreamHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
chunk := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "partial"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 0,
|
||||
ToolUsePromptTokenCount: 0,
|
||||
CandidatesTokenCount: 90,
|
||||
ThoughtsTokenCount: 10,
|
||||
TotalTokenCount: 110,
|
||||
},
|
||||
}
|
||||
|
||||
chunkData, err := common.Marshal(chunk)
|
||||
require.NoError(t, err)
|
||||
|
||||
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(streamBody)),
|
||||
}
|
||||
|
||||
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
|
||||
return true
|
||||
})
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 20, usage.PromptTokens)
|
||||
require.Equal(t, 100, usage.CompletionTokens)
|
||||
require.Equal(t, 110, usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3-flash-preview:generateContent", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
OriginModelName: "gemini-3-flash-preview",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-3-flash-preview",
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(20)
|
||||
|
||||
payload := dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{
|
||||
{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 0,
|
||||
ToolUsePromptTokenCount: 0,
|
||||
CandidatesTokenCount: 90,
|
||||
ThoughtsTokenCount: 10,
|
||||
TotalTokenCount: 110,
|
||||
},
|
||||
}
|
||||
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiTextGenerationHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 20, usage.PromptTokens)
|
||||
require.Equal(t, 100, usage.CompletionTokens)
|
||||
require.Equal(t, 110, usage.TotalTokens)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
"github.com/QuantumNous/new-api/relay/channel/claude"
|
||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/constant"
|
||||
@@ -26,7 +27,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
adaptor := claude.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
@@ -119,8 +121,14 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
return handleTTSResponse(c, resp, info)
|
||||
}
|
||||
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
adaptor := claude.Adaptor{}
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
default:
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetModelList() []string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
channelconstant "github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
@@ -13,13 +14,17 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if baseUrl == "" {
|
||||
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeMiniMax]
|
||||
}
|
||||
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeChatCompletions:
|
||||
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
|
||||
case constant.RelayModeAudioSpeech:
|
||||
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeChatCompletions:
|
||||
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
|
||||
case constant.RelayModeAudioSpeech:
|
||||
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -186,7 +187,22 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
part, err := writer.CreateFormFile(fieldName, fh.Filename)
|
||||
ct := fh.Header.Get("Content-Type")
|
||||
if ct == "" || ct == "application/octet-stream" {
|
||||
buf512 := make([]byte, 512)
|
||||
n, _ := io.ReadFull(f, buf512)
|
||||
ct = http.DetectContentType(buf512[:n])
|
||||
// Re-open after sniffing so the full content is copied below
|
||||
f.Close()
|
||||
f, err = fh.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fh.Filename))
|
||||
h.Set("Content-Type", ct)
|
||||
part, err := writer.CreatePart(h)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
continue
|
||||
|
||||
@@ -2,12 +2,10 @@ package suno
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
@@ -52,13 +50,13 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
|
||||
return
|
||||
}
|
||||
|
||||
if sunoRequest.ContinueClipId != "" {
|
||||
if sunoRequest.TaskID == "" {
|
||||
taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task id is empty"), "invalid_request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
info.OriginTaskID = sunoRequest.TaskID
|
||||
}
|
||||
//if sunoRequest.ContinueClipId != "" {
|
||||
// if sunoRequest.TaskID == "" {
|
||||
// taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task id is empty"), "invalid_request", http.StatusBadRequest)
|
||||
// return
|
||||
// }
|
||||
// info.OriginTaskID = sunoRequest.TaskID
|
||||
//}
|
||||
|
||||
info.Action = action
|
||||
c.Set("task_request", sunoRequest)
|
||||
@@ -142,13 +140,6 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy
|
||||
common.SysLog(fmt.Sprintf("Get Task error: %v", err))
|
||||
return nil, err
|
||||
}
|
||||
defer req.Body.Close()
|
||||
// 设置超时时间
|
||||
timeout := time.Second * 15
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
// 使用带有超时的 context 创建新的请求
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
client, err := service.GetHttpClientWithProxy(proxy)
|
||||
|
||||
@@ -76,7 +76,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings)
|
||||
chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
// remove disabled fields for Claude API
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
)
|
||||
|
||||
func TestApplyParamOverrideTrimPrefix(t *testing.T) {
|
||||
@@ -772,6 +775,76 @@ func TestApplyParamOverrideToUpper(t *testing.T) {
|
||||
assertJSONEqual(t, `{"model":"GPT-4"}`, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsSkipWhenChannelPassThroughEnabled(t *testing.T) {
|
||||
input := `{
|
||||
"service_tier":"flex",
|
||||
"safety_identifier":"user-123",
|
||||
"store":true,
|
||||
"stream_options":{"include_obfuscation":false}
|
||||
}`
|
||||
settings := dto.ChannelOtherSettings{}
|
||||
|
||||
out, err := RemoveDisabledFields([]byte(input), settings, true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, input, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsSkipWhenGlobalPassThroughEnabled(t *testing.T) {
|
||||
original := model_setting.GetGlobalSettings().PassThroughRequestEnabled
|
||||
model_setting.GetGlobalSettings().PassThroughRequestEnabled = true
|
||||
t.Cleanup(func() {
|
||||
model_setting.GetGlobalSettings().PassThroughRequestEnabled = original
|
||||
})
|
||||
|
||||
input := `{
|
||||
"service_tier":"flex",
|
||||
"safety_identifier":"user-123",
|
||||
"stream_options":{"include_obfuscation":false}
|
||||
}`
|
||||
settings := dto.ChannelOtherSettings{}
|
||||
|
||||
out, err := RemoveDisabledFields([]byte(input), settings, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, input, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
|
||||
input := `{
|
||||
"service_tier":"flex",
|
||||
"inference_geo":"eu",
|
||||
"safety_identifier":"user-123",
|
||||
"store":true,
|
||||
"stream_options":{"include_obfuscation":false}
|
||||
}`
|
||||
settings := dto.ChannelOtherSettings{}
|
||||
|
||||
out, err := RemoveDisabledFields([]byte(input), settings, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, `{"store":true}`, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
|
||||
input := `{
|
||||
"inference_geo":"eu",
|
||||
"store":true
|
||||
}`
|
||||
settings := dto.ChannelOtherSettings{
|
||||
AllowInferenceGeo: true,
|
||||
}
|
||||
|
||||
out, err := RemoveDisabledFields([]byte(input), settings, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, `{"inference_geo":"eu","store":true}`, string(out))
|
||||
}
|
||||
|
||||
func assertJSONEqual(t *testing.T, want, got string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -152,7 +152,8 @@ type RelayInfo struct {
|
||||
// RequestConversionChain records request format conversions in order, e.g.
|
||||
// ["openai", "openai_responses"] or ["openai", "claude"].
|
||||
RequestConversionChain []types.RelayFormat
|
||||
// 最终请求到上游的格式 TODO: 当前仅设置了Claude
|
||||
// 最终请求到上游的格式。可由 adaptor 显式设置;
|
||||
// 若为空,调用 GetFinalRequestRelayFormat 会回退到 RequestConversionChain 的最后一项或 RelayFormat。
|
||||
FinalRequestRelayFormat types.RelayFormat
|
||||
|
||||
ThinkingContentInfo
|
||||
@@ -579,6 +580,19 @@ func (info *RelayInfo) AppendRequestConversion(format types.RelayFormat) {
|
||||
info.RequestConversionChain = append(info.RequestConversionChain, format)
|
||||
}
|
||||
|
||||
func (info *RelayInfo) GetFinalRequestRelayFormat() types.RelayFormat {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
if info.FinalRequestRelayFormat != "" {
|
||||
return info.FinalRequestRelayFormat
|
||||
}
|
||||
if n := len(info.RequestConversionChain); n > 0 {
|
||||
return info.RequestConversionChain[n-1]
|
||||
}
|
||||
return info.RelayFormat
|
||||
}
|
||||
|
||||
func GenRelayInfoResponsesCompaction(c *gin.Context, request *dto.OpenAIResponsesCompactionRequest) *RelayInfo {
|
||||
info := genBaseRelayInfo(c, request)
|
||||
if info.RelayMode == relayconstant.RelayModeUnknown {
|
||||
@@ -714,9 +728,15 @@ func FailTaskInfo(reason string) *TaskInfo {
|
||||
|
||||
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
|
||||
// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持)
|
||||
// inference_geo: Claude 数据驻留推理区域字段(仅 Claude 支持,默认过滤)
|
||||
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
|
||||
func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) {
|
||||
// stream_options.include_obfuscation: 响应流混淆控制字段(仅 OpenAI Responses API 支持)
|
||||
func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings, channelPassThroughEnabled bool) ([]byte, error) {
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelPassThroughEnabled {
|
||||
return jsonData, nil
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := common.Unmarshal(jsonData, &data); err != nil {
|
||||
common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error())
|
||||
@@ -730,6 +750,13 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
|
||||
}
|
||||
}
|
||||
|
||||
// 默认移除 inference_geo,除非明确允许(避免在未授权情况下透传数据驻留区域)
|
||||
if !channelOtherSettings.AllowInferenceGeo {
|
||||
if _, exists := data["inference_geo"]; exists {
|
||||
delete(data, "inference_geo")
|
||||
}
|
||||
}
|
||||
|
||||
// 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
|
||||
if channelOtherSettings.DisableStore {
|
||||
if _, exists := data["store"]; exists {
|
||||
@@ -744,6 +771,22 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
|
||||
}
|
||||
}
|
||||
|
||||
// 默认移除 stream_options.include_obfuscation,除非明确允许(避免关闭响应流混淆保护)
|
||||
if !channelOtherSettings.AllowIncludeObfuscation {
|
||||
if streamOptionsAny, exists := data["stream_options"]; exists {
|
||||
if streamOptions, ok := streamOptionsAny.(map[string]interface{}); ok {
|
||||
if _, includeExists := streamOptions["include_obfuscation"]; includeExists {
|
||||
delete(streamOptions, "include_obfuscation")
|
||||
}
|
||||
if len(streamOptions) == 0 {
|
||||
delete(data, "stream_options")
|
||||
} else {
|
||||
data["stream_options"] = streamOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonDataAfter, err := common.Marshal(data)
|
||||
if err != nil {
|
||||
common.SysError("RemoveDisabledFields Marshal error :" + err.Error())
|
||||
|
||||
40
relay/common/relay_info_test.go
Normal file
40
relay/common/relay_info_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRelayInfoGetFinalRequestRelayFormatPrefersExplicitFinal(t *testing.T) {
|
||||
info := &RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatClaude},
|
||||
FinalRequestRelayFormat: types.RelayFormatOpenAIResponses,
|
||||
}
|
||||
|
||||
require.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), info.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
func TestRelayInfoGetFinalRequestRelayFormatFallsBackToConversionChain(t *testing.T) {
|
||||
info := &RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatClaude},
|
||||
}
|
||||
|
||||
require.Equal(t, types.RelayFormat(types.RelayFormatClaude), info.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
func TestRelayInfoGetFinalRequestRelayFormatFallsBackToRelayFormat(t *testing.T) {
|
||||
info := &RelayInfo{
|
||||
RelayFormat: types.RelayFormatGemini,
|
||||
}
|
||||
|
||||
require.Equal(t, types.RelayFormat(types.RelayFormatGemini), info.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
func TestRelayInfoGetFinalRequestRelayFormatNilReceiver(t *testing.T) {
|
||||
var info *RelayInfo
|
||||
require.Equal(t, types.RelayFormat(""), info.GetFinalRequestRelayFormat())
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
}
|
||||
|
||||
// remove disabled fields for OpenAI API
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
}
|
||||
|
||||
if originUsage != nil {
|
||||
service.ObserveChannelAffinityUsageCacheFromContext(ctx, usage)
|
||||
service.ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
||||
@@ -336,7 +336,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
|
||||
var audioInputQuota decimal.Decimal
|
||||
var audioInputPrice float64
|
||||
isClaudeUsageSemantic := relayInfo.FinalRequestRelayFormat == types.RelayFormatClaude
|
||||
isClaudeUsageSemantic := relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude
|
||||
if !relayInfo.PriceData.UsePrice {
|
||||
baseTokens := dPromptTokens
|
||||
// 减去 cached tokens
|
||||
|
||||
@@ -176,10 +176,32 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
})
|
||||
}
|
||||
|
||||
dataChan := make(chan string, 10)
|
||||
|
||||
wg.Add(1)
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
if r := recover(); r != nil {
|
||||
logger.LogError(c, fmt.Sprintf("data handler goroutine panic: %v", r))
|
||||
}
|
||||
common.SafeSendBool(stopChan, true)
|
||||
}()
|
||||
for data := range dataChan {
|
||||
writeMutex.Lock()
|
||||
success := dataHandler(data)
|
||||
writeMutex.Unlock()
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Scanner goroutine with improved error handling
|
||||
wg.Add(1)
|
||||
common.RelayCtxGo(ctx, func() {
|
||||
defer func() {
|
||||
close(dataChan)
|
||||
wg.Done()
|
||||
if r := recover(); r != nil {
|
||||
logger.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r))
|
||||
@@ -215,27 +237,16 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
continue
|
||||
}
|
||||
data = data[5:]
|
||||
data = strings.TrimLeft(data, " ")
|
||||
data = strings.TrimSuffix(data, "\r")
|
||||
data = strings.TrimSpace(data)
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(data, "[DONE]") {
|
||||
info.SetFirstResponseTime()
|
||||
info.ReceivedResponseCount++
|
||||
// 使用超时机制防止写操作阻塞
|
||||
done := make(chan bool, 1)
|
||||
gopool.Go(func() {
|
||||
writeMutex.Lock()
|
||||
defer writeMutex.Unlock()
|
||||
done <- dataHandler(data)
|
||||
})
|
||||
|
||||
select {
|
||||
case success := <-done:
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
logger.LogError(c, "data handler timeout")
|
||||
return
|
||||
case dataChan <- data:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stopChan:
|
||||
|
||||
521
relay/helper/stream_scanner_test.go
Normal file
521
relay/helper/stream_scanner_test.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func setupStreamTest(t *testing.T, body io.Reader) (*gin.Context, *http.Response, *relaycommon.RelayInfo) {
|
||||
t.Helper()
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldTimeout
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(body),
|
||||
}
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{},
|
||||
}
|
||||
|
||||
return c, resp, info
|
||||
}
|
||||
|
||||
func buildSSEBody(n int) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < n; i++ {
|
||||
fmt.Fprintf(&b, "data: {\"id\":%d,\"choices\":[{\"delta\":{\"content\":\"token_%d\"}}]}\n", i, i)
|
||||
}
|
||||
b.WriteString("data: [DONE]\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// slowReader wraps a reader and injects a delay before each Read call,
|
||||
// simulating a slow upstream that trickles data.
|
||||
type slowReader struct {
|
||||
r io.Reader
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (s *slowReader) Read(p []byte) (int, error) {
|
||||
time.Sleep(s.delay)
|
||||
return s.r.Read(p)
|
||||
}
|
||||
|
||||
// ---------- Basic correctness ----------
|
||||
|
||||
func TestStreamScannerHandler_NilInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
|
||||
StreamScannerHandler(c, nil, info, func(data string) bool { return true })
|
||||
StreamScannerHandler(c, &http.Response{Body: io.NopCloser(strings.NewReader(""))}, info, nil)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_EmptyBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(""))
|
||||
|
||||
var called atomic.Bool
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
called.Store(true)
|
||||
return true
|
||||
})
|
||||
|
||||
assert.False(t, called.Load(), "handler should not be called for empty body")
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_1000Chunks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numChunks = 1000
|
||||
body := buildSSEBody(numChunks)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
|
||||
assert.Equal(t, int64(numChunks), count.Load())
|
||||
assert.Equal(t, numChunks, info.ReceivedResponseCount)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_10000Chunks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numChunks = 10000
|
||||
body := buildSSEBody(numChunks)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var count atomic.Int64
|
||||
start := time.Now()
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
|
||||
elapsed := time.Since(start)
|
||||
assert.Equal(t, int64(numChunks), count.Load())
|
||||
assert.Equal(t, numChunks, info.ReceivedResponseCount)
|
||||
t.Logf("10000 chunks processed in %v", elapsed)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_OrderPreserved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numChunks = 500
|
||||
body := buildSSEBody(numChunks)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var mu sync.Mutex
|
||||
received := make([]string, 0, numChunks)
|
||||
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
mu.Lock()
|
||||
received = append(received, data)
|
||||
mu.Unlock()
|
||||
return true
|
||||
})
|
||||
|
||||
require.Equal(t, numChunks, len(received))
|
||||
for i := 0; i < numChunks; i++ {
|
||||
expected := fmt.Sprintf("{\"id\":%d,\"choices\":[{\"delta\":{\"content\":\"token_%d\"}}]}", i, i)
|
||||
assert.Equal(t, expected, received[i], "chunk %d out of order", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_DoneStopsScanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(50) + "data: should_not_appear\n"
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
|
||||
assert.Equal(t, int64(50), count.Load(), "data after [DONE] must not be processed")
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_HandlerFailureStops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numChunks = 200
|
||||
body := buildSSEBody(numChunks)
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
const failAt = 50
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
n := count.Add(1)
|
||||
return n < failAt
|
||||
})
|
||||
|
||||
// The worker stops at failAt; the scanner may have read ahead,
|
||||
// but the handler should not be called beyond failAt.
|
||||
assert.Equal(t, int64(failAt), count.Load())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_SkipsNonDataLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(": comment line\n")
|
||||
b.WriteString("event: message\n")
|
||||
b.WriteString("id: 12345\n")
|
||||
b.WriteString("retry: 5000\n")
|
||||
for i := 0; i < 100; i++ {
|
||||
fmt.Fprintf(&b, "data: payload_%d\n", i)
|
||||
b.WriteString(": interleaved comment\n")
|
||||
}
|
||||
b.WriteString("data: [DONE]\n")
|
||||
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(b.String()))
|
||||
|
||||
var count atomic.Int64
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
|
||||
assert.Equal(t, int64(100), count.Load())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_DataWithExtraSpaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := "data: {\"trimmed\":true} \ndata: [DONE]\n"
|
||||
c, resp, info := setupStreamTest(t, strings.NewReader(body))
|
||||
|
||||
var got string
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
got = data
|
||||
return true
|
||||
})
|
||||
|
||||
assert.Equal(t, "{\"trimmed\":true}", got)
|
||||
}
|
||||
|
||||
// ---------- Decoupling: scanner not blocked by slow handler ----------
|
||||
|
||||
func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Strategy: use a slow upstream (io.Pipe, 10ms per chunk) AND a slow handler (20ms per chunk).
|
||||
// If the scanner were synchronously coupled to the handler, total time would be
|
||||
// ~numChunks * (10ms + 20ms) = 30ms * 50 = 1500ms.
|
||||
// With decoupling, total time should be closer to
|
||||
// ~numChunks * max(10ms, 20ms) = 20ms * 50 = 1000ms
|
||||
// because the scanner reads ahead into the buffer while the handler processes.
|
||||
const numChunks = 50
|
||||
const upstreamDelay = 10 * time.Millisecond
|
||||
const handlerDelay = 20 * time.Millisecond
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
for i := 0; i < numChunks; i++ {
|
||||
fmt.Fprintf(pw, "data: {\"id\":%d}\n", i)
|
||||
time.Sleep(upstreamDelay)
|
||||
}
|
||||
fmt.Fprint(pw, "data: [DONE]\n")
|
||||
}()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
resp := &http.Response{Body: pr}
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
|
||||
var count atomic.Int64
|
||||
start := time.Now()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
time.Sleep(handlerDelay)
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("StreamScannerHandler did not complete in time")
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
assert.Equal(t, int64(numChunks), count.Load())
|
||||
|
||||
coupledTime := time.Duration(numChunks) * (upstreamDelay + handlerDelay)
|
||||
t.Logf("elapsed=%v, coupled_estimate=%v", elapsed, coupledTime)
|
||||
|
||||
// If decoupled, elapsed should be well under the coupled estimate.
|
||||
assert.Less(t, elapsed, coupledTime*85/100,
|
||||
"decoupled elapsed time (%v) should be significantly less than coupled estimate (%v)", elapsed, coupledTime)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_SlowUpstreamFastHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numChunks = 50
|
||||
body := buildSSEBody(numChunks)
|
||||
reader := &slowReader{r: strings.NewReader(body), delay: 2 * time.Millisecond}
|
||||
c, resp, info := setupStreamTest(t, reader)
|
||||
|
||||
var count atomic.Int64
|
||||
start := time.Now()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("timed out with slow upstream")
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
assert.Equal(t, int64(numChunks), count.Load())
|
||||
t.Logf("slow upstream (%d chunks, 2ms/read): %v", numChunks, elapsed)
|
||||
}
|
||||
|
||||
// ---------- Ping tests ----------
|
||||
|
||||
func TestStreamScannerHandler_PingSentDuringSlowUpstream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setting := operation_setting.GetGeneralSetting()
|
||||
oldEnabled := setting.PingIntervalEnabled
|
||||
oldSeconds := setting.PingIntervalSeconds
|
||||
setting.PingIntervalEnabled = true
|
||||
setting.PingIntervalSeconds = 1
|
||||
t.Cleanup(func() {
|
||||
setting.PingIntervalEnabled = oldEnabled
|
||||
setting.PingIntervalSeconds = oldSeconds
|
||||
})
|
||||
|
||||
// Create a reader that delivers data slowly: one chunk every 500ms over 3.5 seconds.
|
||||
// The ping interval is 1s, so we should see at least 2 pings.
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
for i := 0; i < 7; i++ {
|
||||
fmt.Fprintf(pw, "data: chunk_%d\n", i)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
fmt.Fprint(pw, "data: [DONE]\n")
|
||||
}()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldTimeout
|
||||
})
|
||||
|
||||
resp := &http.Response{Body: pr}
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
|
||||
var count atomic.Int64
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("timed out waiting for stream to finish")
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(7), count.Load())
|
||||
|
||||
body := recorder.Body.String()
|
||||
pingCount := strings.Count(body, ": PING")
|
||||
t.Logf("received %d pings in response body", pingCount)
|
||||
assert.GreaterOrEqual(t, pingCount, 2,
|
||||
"expected at least 2 pings during 3.5s stream with 1s interval; got %d", pingCount)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_PingDisabledByRelayInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setting := operation_setting.GetGeneralSetting()
|
||||
oldEnabled := setting.PingIntervalEnabled
|
||||
oldSeconds := setting.PingIntervalSeconds
|
||||
setting.PingIntervalEnabled = true
|
||||
setting.PingIntervalSeconds = 1
|
||||
t.Cleanup(func() {
|
||||
setting.PingIntervalEnabled = oldEnabled
|
||||
setting.PingIntervalSeconds = oldSeconds
|
||||
})
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
for i := 0; i < 5; i++ {
|
||||
fmt.Fprintf(pw, "data: chunk_%d\n", i)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
fmt.Fprint(pw, "data: [DONE]\n")
|
||||
}()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldTimeout
|
||||
})
|
||||
|
||||
resp := &http.Response{Body: pr}
|
||||
info := &relaycommon.RelayInfo{
|
||||
DisablePing: true,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{},
|
||||
}
|
||||
|
||||
var count atomic.Int64
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(5), count.Load())
|
||||
|
||||
body := recorder.Body.String()
|
||||
pingCount := strings.Count(body, ": PING")
|
||||
assert.Equal(t, 0, pingCount, "pings should be disabled when DisablePing=true")
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setting := operation_setting.GetGeneralSetting()
|
||||
oldEnabled := setting.PingIntervalEnabled
|
||||
oldSeconds := setting.PingIntervalSeconds
|
||||
setting.PingIntervalEnabled = true
|
||||
setting.PingIntervalSeconds = 1
|
||||
t.Cleanup(func() {
|
||||
setting.PingIntervalEnabled = oldEnabled
|
||||
setting.PingIntervalSeconds = oldSeconds
|
||||
})
|
||||
|
||||
// Slow upstream + slow handler. Total stream takes ~5 seconds.
|
||||
// The ping goroutine stays alive as long as the scanner is reading,
|
||||
// so pings should fire between data writes.
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
for i := 0; i < 10; i++ {
|
||||
fmt.Fprintf(pw, "data: chunk_%d\n", i)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
fmt.Fprint(pw, "data: [DONE]\n")
|
||||
}()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldTimeout
|
||||
})
|
||||
|
||||
resp := &http.Response{Body: pr}
|
||||
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
|
||||
var count atomic.Int64
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
count.Add(1)
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(10), count.Load())
|
||||
|
||||
body := recorder.Body.String()
|
||||
pingCount := strings.Count(body, ": PING")
|
||||
t.Logf("received %d pings interleaved with 10 chunks over 5s", pingCount)
|
||||
assert.GreaterOrEqual(t, pingCount, 3,
|
||||
"expected at least 3 pings during 5s stream with 1s ping interval; got %d", pingCount)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyR
|
||||
if swapFaceRequest.SourceBase64 == "" || swapFaceRequest.TargetBase64 == "" {
|
||||
return service.MidjourneyErrorWrapper(constant.MjRequestError, "sour_base64_and_target_base64_is_required")
|
||||
}
|
||||
modelName := service.CoverActionToModelName(constant.MjActionSwapFace)
|
||||
modelName := service.CovertMjpActionToModelName(constant.MjActionSwapFace)
|
||||
|
||||
priceData := helper.ModelPriceHelperPerCall(c, info)
|
||||
|
||||
@@ -485,7 +485,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
|
||||
|
||||
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
|
||||
|
||||
modelName := service.CoverActionToModelName(midjRequest.Action)
|
||||
modelName := service.CovertMjpActionToModelName(midjRequest.Action)
|
||||
|
||||
priceData := helper.ModelPriceHelperPerCall(c, relayInfo)
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
|
||||
// remove disabled fields for OpenAI Responses API
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
|
||||
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter := router.Group("/api")
|
||||
apiRouter.Use(middleware.RouteTag("api"))
|
||||
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
apiRouter.Use(middleware.BodyStorageCleanup()) // 清理请求体存储
|
||||
apiRouter.Use(middleware.GlobalAPIRateLimit())
|
||||
@@ -114,6 +115,9 @@ func SetApiRouter(router *gin.Engine) {
|
||||
adminRoute.GET("/topup", controller.GetAllTopUps)
|
||||
adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp)
|
||||
adminRoute.GET("/search", controller.SearchUsers)
|
||||
adminRoute.GET("/:id/oauth/bindings", controller.GetUserOAuthBindingsByAdmin)
|
||||
adminRoute.DELETE("/:id/oauth/bindings/:provider_id", controller.UnbindCustomOAuthByAdmin)
|
||||
adminRoute.DELETE("/:id/bindings/:binding_type", controller.AdminClearUserBinding)
|
||||
adminRoute.GET("/:id", controller.GetUser)
|
||||
adminRoute.POST("/", controller.CreateUser)
|
||||
adminRoute.POST("/manage", controller.ManageUser)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
func SetDashboardRouter(router *gin.Engine) {
|
||||
apiRouter := router.Group("/")
|
||||
apiRouter.Use(middleware.RouteTag("old_api"))
|
||||
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
apiRouter.Use(middleware.GlobalAPIRateLimit())
|
||||
apiRouter.Use(middleware.CORS())
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -27,6 +28,7 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
} else {
|
||||
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.Set(middleware.RouteTagKey, "web")
|
||||
c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
router.Use(middleware.StatsMiddleware())
|
||||
// https://platform.openai.com/docs/api-reference/introduction
|
||||
modelsRouter := router.Group("/v1/models")
|
||||
modelsRouter.Use(middleware.RouteTag("relay"))
|
||||
modelsRouter.Use(middleware.TokenAuth())
|
||||
{
|
||||
modelsRouter.GET("", func(c *gin.Context) {
|
||||
@@ -41,6 +42,7 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
geminiRouter := router.Group("/v1beta/models")
|
||||
geminiRouter.Use(middleware.RouteTag("relay"))
|
||||
geminiRouter.Use(middleware.TokenAuth())
|
||||
{
|
||||
geminiRouter.GET("", func(c *gin.Context) {
|
||||
@@ -49,6 +51,7 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
geminiCompatibleRouter := router.Group("/v1beta/openai/models")
|
||||
geminiCompatibleRouter.Use(middleware.RouteTag("relay"))
|
||||
geminiCompatibleRouter.Use(middleware.TokenAuth())
|
||||
{
|
||||
geminiCompatibleRouter.GET("", func(c *gin.Context) {
|
||||
@@ -57,12 +60,14 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
playgroundRouter := router.Group("/pg")
|
||||
playgroundRouter.Use(middleware.RouteTag("relay"))
|
||||
playgroundRouter.Use(middleware.SystemPerformanceCheck())
|
||||
playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute())
|
||||
{
|
||||
playgroundRouter.POST("/chat/completions", controller.Playground)
|
||||
}
|
||||
relayV1Router := router.Group("/v1")
|
||||
relayV1Router.Use(middleware.RouteTag("relay"))
|
||||
relayV1Router.Use(middleware.SystemPerformanceCheck())
|
||||
relayV1Router.Use(middleware.TokenAuth())
|
||||
relayV1Router.Use(middleware.ModelRequestRateLimit())
|
||||
@@ -161,15 +166,18 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
relayMjRouter := router.Group("/mj")
|
||||
relayMjRouter.Use(middleware.RouteTag("relay"))
|
||||
relayMjRouter.Use(middleware.SystemPerformanceCheck())
|
||||
registerMjRouterGroup(relayMjRouter)
|
||||
|
||||
relayMjModeRouter := router.Group("/:mode/mj")
|
||||
relayMjModeRouter.Use(middleware.RouteTag("relay"))
|
||||
relayMjModeRouter.Use(middleware.SystemPerformanceCheck())
|
||||
registerMjRouterGroup(relayMjModeRouter)
|
||||
//relayMjRouter.Use()
|
||||
|
||||
relaySunoRouter := router.Group("/suno")
|
||||
relaySunoRouter.Use(middleware.RouteTag("relay"))
|
||||
relaySunoRouter.Use(middleware.SystemPerformanceCheck())
|
||||
relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
@@ -179,6 +187,7 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
relayGeminiRouter := router.Group("/v1beta")
|
||||
relayGeminiRouter.Use(middleware.RouteTag("relay"))
|
||||
relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
|
||||
relayGeminiRouter.Use(middleware.TokenAuth())
|
||||
relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
func SetVideoRouter(router *gin.Engine) {
|
||||
// Video proxy: accepts either session auth (dashboard) or token auth (API clients)
|
||||
videoProxyRouter := router.Group("/v1")
|
||||
videoProxyRouter.Use(middleware.RouteTag("relay"))
|
||||
videoProxyRouter.Use(middleware.TokenOrUserAuth())
|
||||
{
|
||||
videoProxyRouter.GET("/videos/:task_id/content", controller.VideoProxy)
|
||||
}
|
||||
|
||||
videoV1Router := router.Group("/v1")
|
||||
videoV1Router.Use(middleware.RouteTag("relay"))
|
||||
videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
videoV1Router.POST("/video/generations", controller.RelayTask)
|
||||
@@ -30,6 +32,7 @@ func SetVideoRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
klingV1Router := router.Group("/kling/v1")
|
||||
klingV1Router.Use(middleware.RouteTag("relay"))
|
||||
klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
klingV1Router.POST("/videos/text2video", controller.RelayTask)
|
||||
@@ -40,6 +43,7 @@ func SetVideoRouter(router *gin.Engine) {
|
||||
|
||||
// Jimeng official API routes - direct mapping to official API format
|
||||
jimengOfficialGroup := router.Group("jimeng")
|
||||
jimengOfficialGroup.Use(middleware.RouteTag("relay"))
|
||||
jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
// Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31
|
||||
|
||||
@@ -19,6 +19,7 @@ func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
router.Use(middleware.Cache())
|
||||
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist")))
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.Set(middleware.RouteTagKey, "web")
|
||||
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
|
||||
controller.RelayNotFound(c)
|
||||
return
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/pkg/cachex"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/hot"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -61,6 +62,12 @@ type ChannelAffinityStatsContext struct {
|
||||
TTLSeconds int64
|
||||
}
|
||||
|
||||
const (
|
||||
cacheTokenRateModeCachedOverPrompt = "cached_over_prompt"
|
||||
cacheTokenRateModeCachedOverPromptPlusCached = "cached_over_prompt_plus_cached"
|
||||
cacheTokenRateModeMixed = "mixed"
|
||||
)
|
||||
|
||||
type ChannelAffinityCacheStats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Total int `json:"total"`
|
||||
@@ -565,9 +572,10 @@ func RecordChannelAffinity(c *gin.Context, channelID int) {
|
||||
}
|
||||
|
||||
type ChannelAffinityUsageCacheStats struct {
|
||||
RuleName string `json:"rule_name"`
|
||||
UsingGroup string `json:"using_group"`
|
||||
KeyFingerprint string `json:"key_fp"`
|
||||
RuleName string `json:"rule_name"`
|
||||
UsingGroup string `json:"using_group"`
|
||||
KeyFingerprint string `json:"key_fp"`
|
||||
CachedTokenRateMode string `json:"cached_token_rate_mode"`
|
||||
|
||||
Hit int64 `json:"hit"`
|
||||
Total int64 `json:"total"`
|
||||
@@ -582,6 +590,8 @@ type ChannelAffinityUsageCacheStats struct {
|
||||
}
|
||||
|
||||
type ChannelAffinityUsageCacheCounters struct {
|
||||
CachedTokenRateMode string `json:"cached_token_rate_mode"`
|
||||
|
||||
Hit int64 `json:"hit"`
|
||||
Total int64 `json:"total"`
|
||||
WindowSeconds int64 `json:"window_seconds"`
|
||||
@@ -596,12 +606,17 @@ type ChannelAffinityUsageCacheCounters struct {
|
||||
|
||||
var channelAffinityUsageCacheStatsLocks [64]sync.Mutex
|
||||
|
||||
func ObserveChannelAffinityUsageCacheFromContext(c *gin.Context, usage *dto.Usage) {
|
||||
// ObserveChannelAffinityUsageCacheByRelayFormat records usage cache stats with a stable rate mode derived from relay format.
|
||||
func ObserveChannelAffinityUsageCacheByRelayFormat(c *gin.Context, usage *dto.Usage, relayFormat types.RelayFormat) {
|
||||
ObserveChannelAffinityUsageCacheFromContext(c, usage, cachedTokenRateModeByRelayFormat(relayFormat))
|
||||
}
|
||||
|
||||
func ObserveChannelAffinityUsageCacheFromContext(c *gin.Context, usage *dto.Usage, cachedTokenRateMode string) {
|
||||
statsCtx, ok := GetChannelAffinityStatsContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
observeChannelAffinityUsageCache(statsCtx, usage)
|
||||
observeChannelAffinityUsageCache(statsCtx, usage, cachedTokenRateMode)
|
||||
}
|
||||
|
||||
func GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp string) ChannelAffinityUsageCacheStats {
|
||||
@@ -628,6 +643,7 @@ func GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp string) Chann
|
||||
}
|
||||
}
|
||||
return ChannelAffinityUsageCacheStats{
|
||||
CachedTokenRateMode: v.CachedTokenRateMode,
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
@@ -643,7 +659,7 @@ func GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp string) Chann
|
||||
}
|
||||
}
|
||||
|
||||
func observeChannelAffinityUsageCache(statsCtx ChannelAffinityStatsContext, usage *dto.Usage) {
|
||||
func observeChannelAffinityUsageCache(statsCtx ChannelAffinityStatsContext, usage *dto.Usage, cachedTokenRateMode string) {
|
||||
entryKey := channelAffinityUsageCacheEntryKey(statsCtx.RuleName, statsCtx.UsingGroup, statsCtx.KeyFingerprint)
|
||||
if entryKey == "" {
|
||||
return
|
||||
@@ -669,6 +685,14 @@ func observeChannelAffinityUsageCache(statsCtx ChannelAffinityStatsContext, usag
|
||||
if !found {
|
||||
next = ChannelAffinityUsageCacheCounters{}
|
||||
}
|
||||
currentMode := normalizeCachedTokenRateMode(cachedTokenRateMode)
|
||||
if currentMode != "" {
|
||||
if next.CachedTokenRateMode == "" {
|
||||
next.CachedTokenRateMode = currentMode
|
||||
} else if next.CachedTokenRateMode != currentMode && next.CachedTokenRateMode != cacheTokenRateModeMixed {
|
||||
next.CachedTokenRateMode = cacheTokenRateModeMixed
|
||||
}
|
||||
}
|
||||
next.Total++
|
||||
hit, cachedTokens, promptCacheHitTokens := usageCacheSignals(usage)
|
||||
if hit {
|
||||
@@ -684,6 +708,30 @@ func observeChannelAffinityUsageCache(statsCtx ChannelAffinityStatsContext, usag
|
||||
_ = cache.SetWithTTL(entryKey, next, ttl)
|
||||
}
|
||||
|
||||
func normalizeCachedTokenRateMode(mode string) string {
|
||||
switch mode {
|
||||
case cacheTokenRateModeCachedOverPrompt:
|
||||
return cacheTokenRateModeCachedOverPrompt
|
||||
case cacheTokenRateModeCachedOverPromptPlusCached:
|
||||
return cacheTokenRateModeCachedOverPromptPlusCached
|
||||
case cacheTokenRateModeMixed:
|
||||
return cacheTokenRateModeMixed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func cachedTokenRateModeByRelayFormat(relayFormat types.RelayFormat) string {
|
||||
switch relayFormat {
|
||||
case types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, types.RelayFormatOpenAIResponsesCompaction:
|
||||
return cacheTokenRateModeCachedOverPrompt
|
||||
case types.RelayFormatClaude:
|
||||
return cacheTokenRateModeCachedOverPromptPlusCached
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp string) string {
|
||||
ruleName = strings.TrimSpace(ruleName)
|
||||
usingGroup = strings.TrimSpace(usingGroup)
|
||||
|
||||
105
service/channel_affinity_usage_cache_test.go
Normal file
105
service/channel_affinity_usage_cache_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) *gin.Context {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
setChannelAffinityContext(ctx, channelAffinityMeta{
|
||||
CacheKey: fmt.Sprintf("test:%s:%s:%s", ruleName, usingGroup, keyFP),
|
||||
TTLSeconds: 600,
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFP,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) {
|
||||
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
|
||||
usingGroup := "default"
|
||||
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
|
||||
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 40,
|
||||
TotalTokens: 140,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, types.RelayFormatClaude)
|
||||
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
|
||||
|
||||
require.EqualValues(t, 1, stats.Total)
|
||||
require.EqualValues(t, 1, stats.Hit)
|
||||
require.EqualValues(t, 100, stats.PromptTokens)
|
||||
require.EqualValues(t, 40, stats.CompletionTokens)
|
||||
require.EqualValues(t, 140, stats.TotalTokens)
|
||||
require.EqualValues(t, 30, stats.CachedTokens)
|
||||
require.Equal(t, cacheTokenRateModeCachedOverPromptPlusCached, stats.CachedTokenRateMode)
|
||||
}
|
||||
|
||||
func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) {
|
||||
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
|
||||
usingGroup := "default"
|
||||
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
|
||||
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
|
||||
|
||||
openAIUsage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 10,
|
||||
},
|
||||
}
|
||||
claudeUsage := &dto.Usage{
|
||||
PromptTokens: 80,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 20,
|
||||
},
|
||||
}
|
||||
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, openAIUsage, types.RelayFormatOpenAI)
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, claudeUsage, types.RelayFormatClaude)
|
||||
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
|
||||
|
||||
require.EqualValues(t, 2, stats.Total)
|
||||
require.EqualValues(t, 2, stats.Hit)
|
||||
require.EqualValues(t, 180, stats.PromptTokens)
|
||||
require.EqualValues(t, 30, stats.CachedTokens)
|
||||
require.Equal(t, cacheTokenRateModeMixed, stats.CachedTokenRateMode)
|
||||
}
|
||||
|
||||
func TestObserveChannelAffinityUsageCacheByRelayFormat_UnsupportedModeKeepsEmpty(t *testing.T) {
|
||||
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
|
||||
usingGroup := "default"
|
||||
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
|
||||
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 25,
|
||||
},
|
||||
}
|
||||
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, types.RelayFormatGemini)
|
||||
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
|
||||
|
||||
require.EqualValues(t, 1, stats.Total)
|
||||
require.EqualValues(t, 1, stats.Hit)
|
||||
require.EqualValues(t, 25, stats.CachedTokens)
|
||||
require.Equal(t, "", stats.CachedTokenRateMode)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts Code
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := RefreshCodexOAuthToken(refreshCtx, oauthKey.RefreshToken)
|
||||
res, err := RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, ch.GetSetting().Proxy)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -38,12 +40,26 @@ type CodexOAuthAuthorizationFlow struct {
|
||||
}
|
||||
|
||||
func RefreshCodexOAuthToken(ctx context.Context, refreshToken string) (*CodexOAuthTokenResult, error) {
|
||||
client := &http.Client{Timeout: defaultHTTPTimeout}
|
||||
return RefreshCodexOAuthTokenWithProxy(ctx, refreshToken, "")
|
||||
}
|
||||
|
||||
func RefreshCodexOAuthTokenWithProxy(ctx context.Context, refreshToken string, proxyURL string) (*CodexOAuthTokenResult, error) {
|
||||
client, err := getCodexOAuthHTTPClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return refreshCodexOAuthToken(ctx, client, codexOAuthTokenURL, codexOAuthClientID, refreshToken)
|
||||
}
|
||||
|
||||
func ExchangeCodexAuthorizationCode(ctx context.Context, code string, verifier string) (*CodexOAuthTokenResult, error) {
|
||||
client := &http.Client{Timeout: defaultHTTPTimeout}
|
||||
return ExchangeCodexAuthorizationCodeWithProxy(ctx, code, verifier, "")
|
||||
}
|
||||
|
||||
func ExchangeCodexAuthorizationCodeWithProxy(ctx context.Context, code string, verifier string, proxyURL string) (*CodexOAuthTokenResult, error) {
|
||||
client, err := getCodexOAuthHTTPClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exchangeCodexAuthorizationCode(ctx, client, codexOAuthTokenURL, codexOAuthClientID, code, verifier, codexOAuthRedirectURI)
|
||||
}
|
||||
|
||||
@@ -104,7 +120,7 @@ func refreshCodexOAuthToken(
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
if err := common.DecodeJson(resp.Body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
@@ -165,7 +181,7 @@ func exchangeCodexAuthorizationCode(
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
if err := common.DecodeJson(resp.Body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
@@ -181,6 +197,19 @@ func exchangeCodexAuthorizationCode(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getCodexOAuthHTTPClient(proxyURL string) (*http.Client, error) {
|
||||
baseClient, err := GetHttpClientWithProxy(strings.TrimSpace(proxyURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if baseClient == nil {
|
||||
return &http.Client{Timeout: defaultHTTPTimeout}, nil
|
||||
}
|
||||
clientCopy := *baseClient
|
||||
clientCopy.Timeout = defaultHTTPTimeout
|
||||
return &clientCopy, nil
|
||||
}
|
||||
|
||||
func buildCodexAuthorizeURL(state string, challenge string) (string, error) {
|
||||
u, err := url.Parse(codexOAuthAuthorizeURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CoverActionToModelName(mjAction string) string {
|
||||
func CovertMjpActionToModelName(mjAction string) string {
|
||||
modelName := "mj_" + strings.ToLower(mjAction)
|
||||
if mjAction == constant.MjActionSwapFace {
|
||||
modelName = "swap_face"
|
||||
@@ -70,7 +70,7 @@ func GetMjRequestModel(relayMode int, midjRequest *dto.MidjourneyRequest) (strin
|
||||
return "", MidjourneyErrorWrapper(constant.MjRequestError, "unknown_relay_action"), false
|
||||
}
|
||||
}
|
||||
modelName := CoverActionToModelName(action)
|
||||
modelName := CovertMjpActionToModelName(action)
|
||||
return modelName, nil, true
|
||||
}
|
||||
|
||||
|
||||
@@ -236,6 +236,9 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
|
||||
}
|
||||
|
||||
func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) {
|
||||
if usage != nil {
|
||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||
promptTokens := usage.PromptTokens
|
||||
|
||||
@@ -35,12 +35,65 @@ type TaskPollingAdaptor interface {
|
||||
// 打破 service -> relay -> relay/channel -> service 的循环依赖。
|
||||
var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor
|
||||
|
||||
// sweepTimedOutTasks 在主轮询之前独立清理超时任务。
|
||||
// 每次最多处理 100 条,剩余的下个周期继续处理。
|
||||
// 使用 per-task CAS (UpdateWithStatus) 防止覆盖被正常轮询已推进的任务。
|
||||
func sweepTimedOutTasks(ctx context.Context) {
|
||||
if constant.TaskTimeoutMinutes <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Unix() - int64(constant.TaskTimeoutMinutes)*60
|
||||
tasks := model.GetTimedOutUnfinishedTasks(cutoff, 100)
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
const legacyTaskCutoff int64 = 1740182400 // 2026-02-22 00:00:00 UTC
|
||||
reason := fmt.Sprintf("任务超时(%d分钟)", constant.TaskTimeoutMinutes)
|
||||
legacyReason := "任务超时(旧系统遗留任务,不进行退款,请联系管理员)"
|
||||
now := time.Now().Unix()
|
||||
timedOutCount := 0
|
||||
|
||||
for _, task := range tasks {
|
||||
isLegacy := task.SubmitTime > 0 && task.SubmitTime < legacyTaskCutoff
|
||||
|
||||
oldStatus := task.Status
|
||||
task.Status = model.TaskStatusFailure
|
||||
task.Progress = "100%"
|
||||
task.FinishTime = now
|
||||
if isLegacy {
|
||||
task.FailReason = legacyReason
|
||||
} else {
|
||||
task.FailReason = reason
|
||||
}
|
||||
|
||||
won, err := task.UpdateWithStatus(oldStatus)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks CAS update error for task %s: %v", task.TaskID, err))
|
||||
continue
|
||||
}
|
||||
if !won {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: task %s already transitioned, skip", task.TaskID))
|
||||
continue
|
||||
}
|
||||
timedOutCount++
|
||||
if !isLegacy && task.Quota != 0 {
|
||||
RefundTaskQuota(ctx, task, reason)
|
||||
}
|
||||
}
|
||||
|
||||
if timedOutCount > 0 {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: timed out %d tasks", timedOutCount))
|
||||
}
|
||||
}
|
||||
|
||||
// TaskPollingLoop 主轮询循环,每 15 秒检查一次未完成的任务
|
||||
func TaskPollingLoop() {
|
||||
for {
|
||||
time.Sleep(time.Duration(15) * time.Second)
|
||||
common.SysLog("任务进度轮询开始")
|
||||
ctx := context.TODO()
|
||||
sweepTimedOutTasks(ctx)
|
||||
allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit)
|
||||
platformTask := make(map[constant.TaskPlatform][]*model.Task)
|
||||
for _, t := range allTasks {
|
||||
@@ -153,13 +206,13 @@ func updateSunoTasks(ctx context.Context, channelId int, taskIds []string, taskM
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("Get Task parse body error: %v", err))
|
||||
common.SysLog(fmt.Sprintf("Get Suno Task parse body error: %v", err))
|
||||
return err
|
||||
}
|
||||
var responseItems dto.TaskResponse[[]dto.SunoDataResponse]
|
||||
err = common.Unmarshal(responseBody, &responseItems)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
logger.LogError(ctx, fmt.Sprintf("Get Suno Task parse body error2: %v, body: %s", err, string(responseBody)))
|
||||
return err
|
||||
}
|
||||
if !responseItems.IsSuccess() {
|
||||
@@ -368,6 +421,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
}
|
||||
if strings.HasPrefix(taskResult.Url, "data:") {
|
||||
// data: URI (e.g. Vertex base64 encoded video) — keep in Data, not in ResultURL
|
||||
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
|
||||
} else if taskResult.Url != "" {
|
||||
// Direct upstream URL (e.g. Kling, Ali, Doubao, etc.)
|
||||
task.PrivateData.ResultURL = taskResult.Url
|
||||
|
||||
@@ -18,8 +18,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ViolationFeeCodePrefix = "violation_fee."
|
||||
CSAMViolationMarker = "Failed check: SAFETY_CHECK_TYPE_CSAM"
|
||||
ViolationFeeCodePrefix = "violation_fee."
|
||||
CSAMViolationMarker = "Failed check: SAFETY_CHECK_TYPE"
|
||||
ContentViolatesUsageMarker = "Content violates usage guidelines"
|
||||
)
|
||||
|
||||
func IsViolationFeeCode(code types.ErrorCode) bool {
|
||||
@@ -30,11 +31,11 @@ func HasCSAMViolationMarker(err *types.NewAPIError) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(err.Error(), CSAMViolationMarker) {
|
||||
if strings.Contains(err.Error(), CSAMViolationMarker) || strings.Contains(err.Error(), ContentViolatesUsageMarker) {
|
||||
return true
|
||||
}
|
||||
msg := err.ToOpenAIError().Message
|
||||
return strings.Contains(msg, CSAMViolationMarker)
|
||||
return strings.Contains(msg, CSAMViolationMarker) || strings.Contains(err.Error(), ContentViolatesUsageMarker)
|
||||
}
|
||||
|
||||
func WrapAsViolationFeeGrokCSAM(err *types.NewAPIError) *types.NewAPIError {
|
||||
|
||||
@@ -26,6 +26,11 @@ var AutomaticRetryStatusCodeRanges = []StatusCodeRange{
|
||||
{Start: 525, End: 599},
|
||||
}
|
||||
|
||||
var alwaysSkipRetryStatusCodes = map[int]struct{}{
|
||||
504: {},
|
||||
524: {},
|
||||
}
|
||||
|
||||
func AutomaticDisableStatusCodesToString() string {
|
||||
return statusCodeRangesToString(AutomaticDisableStatusCodeRanges)
|
||||
}
|
||||
@@ -56,7 +61,15 @@ func AutomaticRetryStatusCodesFromString(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAlwaysSkipRetryStatusCode(code int) bool {
|
||||
_, exists := alwaysSkipRetryStatusCodes[code]
|
||||
return exists
|
||||
}
|
||||
|
||||
func ShouldRetryByStatusCode(code int) bool {
|
||||
if IsAlwaysSkipRetryStatusCode(code) {
|
||||
return false
|
||||
}
|
||||
return shouldMatchStatusCodeRanges(AutomaticRetryStatusCodeRanges, code)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ func TestShouldRetryByStatusCode(t *testing.T) {
|
||||
|
||||
require.True(t, ShouldRetryByStatusCode(429))
|
||||
require.True(t, ShouldRetryByStatusCode(500))
|
||||
require.False(t, ShouldRetryByStatusCode(504))
|
||||
require.False(t, ShouldRetryByStatusCode(524))
|
||||
require.False(t, ShouldRetryByStatusCode(400))
|
||||
require.False(t, ShouldRetryByStatusCode(200))
|
||||
}
|
||||
@@ -77,3 +79,9 @@ func TestShouldRetryByStatusCode_DefaultMatchesLegacyBehavior(t *testing.T) {
|
||||
require.False(t, ShouldRetryByStatusCode(524))
|
||||
require.True(t, ShouldRetryByStatusCode(599))
|
||||
}
|
||||
|
||||
func TestIsAlwaysSkipRetryStatusCode(t *testing.T) {
|
||||
require.True(t, IsAlwaysSkipRetryStatusCode(504))
|
||||
require.True(t, IsAlwaysSkipRetryStatusCode(524))
|
||||
require.False(t, IsAlwaysSkipRetryStatusCode(500))
|
||||
}
|
||||
|
||||
214
web/src/components/common/modals/RiskAcknowledgementModal.jsx
Normal file
214
web/src/components/common/modals/RiskAcknowledgementModal.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
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, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Input,
|
||||
Space,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const RiskMarkdownBlock = React.memo(function RiskMarkdownBlock({
|
||||
markdownContent,
|
||||
}) {
|
||||
if (!markdownContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='rounded-lg'
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-warning-light-hover)',
|
||||
padding: '12px',
|
||||
contentVisibility: 'auto',
|
||||
}}
|
||||
>
|
||||
<MarkdownRenderer content={markdownContent} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
visible,
|
||||
title,
|
||||
markdownContent = '',
|
||||
detailTitle = '',
|
||||
detailItems = [],
|
||||
checklist = [],
|
||||
inputPrompt = '',
|
||||
requiredText = '',
|
||||
inputPlaceholder = '',
|
||||
mismatchText = '',
|
||||
cancelText = '',
|
||||
confirmText = '',
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [checkedItems, setCheckedItems] = useState([]);
|
||||
const [typedText, setTypedText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setCheckedItems(Array(checklist.length).fill(false));
|
||||
setTypedText('');
|
||||
}, [visible, checklist.length]);
|
||||
|
||||
const allChecked = useMemo(() => {
|
||||
if (checklist.length === 0) return true;
|
||||
return checkedItems.length === checklist.length && checkedItems.every(Boolean);
|
||||
}, [checkedItems, checklist.length]);
|
||||
|
||||
const typedMatched = useMemo(() => {
|
||||
if (!requiredText) return true;
|
||||
return typedText.trim() === requiredText.trim();
|
||||
}, [typedText, requiredText]);
|
||||
|
||||
const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
|
||||
const canConfirm = allChecked && typedMatched;
|
||||
|
||||
const handleChecklistChange = useCallback((index, checked) => {
|
||||
setCheckedItems((previous) => {
|
||||
const next = [...previous];
|
||||
next[index] = checked;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={
|
||||
<Space align='center'>
|
||||
<IconAlertTriangle style={{ color: 'var(--semi-color-warning)' }} />
|
||||
<span>{title}</span>
|
||||
</Space>
|
||||
}
|
||||
width={isMobile ? '100%' : 860}
|
||||
centered
|
||||
maskClosable={false}
|
||||
closeOnEsc={false}
|
||||
onCancel={onCancel}
|
||||
bodyStyle={{
|
||||
maxHeight: isMobile ? '70vh' : '72vh',
|
||||
overflowY: 'auto',
|
||||
padding: isMobile ? '12px 16px' : '18px 22px',
|
||||
}}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onCancel}>{cancelText}</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='danger'
|
||||
disabled={!canConfirm}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
<RiskMarkdownBlock markdownContent={markdownContent} />
|
||||
|
||||
{detailItems.length > 0 ? (
|
||||
<div
|
||||
className='flex flex-col gap-2 rounded-lg'
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-warning-light-hover)',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
||||
}}
|
||||
>
|
||||
{detailTitle ? <Text strong>{detailTitle}</Text> : null}
|
||||
<div className='font-mono text-xs break-all bg-orange-50 border border-orange-200 rounded-md p-2'>
|
||||
{detailText}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{checklist.length > 0 ? (
|
||||
<div
|
||||
className='flex flex-col gap-2 rounded-lg'
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
||||
}}
|
||||
>
|
||||
{checklist.map((item, index) => (
|
||||
<Checkbox
|
||||
key={`risk-check-${index}`}
|
||||
checked={!!checkedItems[index]}
|
||||
onChange={(event) => {
|
||||
handleChecklistChange(index, event.target.checked);
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requiredText ? (
|
||||
<div
|
||||
className='flex flex-col gap-2 rounded-lg'
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-danger-light-hover)',
|
||||
background: 'var(--semi-color-danger-light-default)',
|
||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
||||
}}
|
||||
>
|
||||
{inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
|
||||
<div className='font-mono text-xs break-all rounded-md p-2 bg-gray-50 border border-gray-200'>
|
||||
{requiredText}
|
||||
</div>
|
||||
<Input
|
||||
value={typedText}
|
||||
onChange={setTypedText}
|
||||
placeholder={inputPlaceholder}
|
||||
autoFocus={visible}
|
||||
onCopy={(event) => event.preventDefault()}
|
||||
onCut={(event) => event.preventDefault()}
|
||||
onPaste={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
/>
|
||||
{!typedMatched && typedText ? (
|
||||
<Text type='danger' size='small'>
|
||||
{mismatchText}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default RiskAcknowledgementModal;
|
||||
@@ -86,6 +86,7 @@ const NotificationSettings = ({
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
subscription: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
@@ -169,6 +170,7 @@ const NotificationSettings = ({
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
subscription: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
@@ -296,6 +298,11 @@ const NotificationSettings = ({
|
||||
title: t('模型部署'),
|
||||
description: t('模型部署管理'),
|
||||
},
|
||||
{
|
||||
key: 'subscription',
|
||||
title: t('订阅管理'),
|
||||
description: t('订阅套餐管理'),
|
||||
},
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
|
||||
@@ -61,9 +61,14 @@ import OllamaModelModal from './OllamaModelModal';
|
||||
import CodexOAuthModal from './CodexOAuthModal';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
|
||||
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
||||
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
|
||||
import { createApiCalls } from '../../../../services/secureVerification';
|
||||
import {
|
||||
collectInvalidStatusCodeEntries,
|
||||
collectNewDisallowedStatusCodeRedirects,
|
||||
} from './statusCodeRiskGuard';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
@@ -170,6 +175,8 @@ const EditChannelModal = (props) => {
|
||||
allow_service_tier: false,
|
||||
disable_store: false, // false = 允许透传(默认开启)
|
||||
allow_safety_identifier: false,
|
||||
allow_include_obfuscation: false,
|
||||
allow_inference_geo: false,
|
||||
claude_beta_query: false,
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
@@ -184,6 +191,7 @@ const EditChannelModal = (props) => {
|
||||
const [fullModels, setFullModels] = useState([]);
|
||||
const [modelGroups, setModelGroups] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [modelSearchValue, setModelSearchValue] = useState('');
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [modelModalVisible, setModelModalVisible] = useState(false);
|
||||
@@ -224,6 +232,25 @@ const EditChannelModal = (props) => {
|
||||
return [];
|
||||
}
|
||||
}, [inputs.model_mapping]);
|
||||
const modelSearchMatchedCount = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword) {
|
||||
return modelOptions.length;
|
||||
}
|
||||
return modelOptions.reduce(
|
||||
(count, option) => count + (selectFilter(keyword, option) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}, [modelOptions, modelSearchValue]);
|
||||
const modelSearchHintText = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword || modelSearchMatchedCount !== 0) {
|
||||
return '';
|
||||
}
|
||||
return t('未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加', {
|
||||
name: keyword,
|
||||
});
|
||||
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
||||
const [isIonetChannel, setIsIonetChannel] = useState(false);
|
||||
const [ionetMetadata, setIonetMetadata] = useState(null);
|
||||
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
|
||||
@@ -255,6 +282,12 @@ const EditChannelModal = (props) => {
|
||||
window.open(targetUrl, '_blank', 'noopener');
|
||||
};
|
||||
const [verifyLoading, setVerifyLoading] = useState(false);
|
||||
const statusCodeRiskConfirmResolverRef = useRef(null);
|
||||
const [statusCodeRiskConfirmVisible, setStatusCodeRiskConfirmVisible] =
|
||||
useState(false);
|
||||
const [statusCodeRiskDetailItems, setStatusCodeRiskDetailItems] = useState(
|
||||
[],
|
||||
);
|
||||
|
||||
// 表单块导航相关状态
|
||||
const formSectionRefs = useRef({
|
||||
@@ -276,6 +309,7 @@ const EditChannelModal = (props) => {
|
||||
const doubaoApiClickCountRef = useRef(0);
|
||||
const initialModelsRef = useRef([]);
|
||||
const initialModelMappingRef = useRef('');
|
||||
const initialStatusCodeMappingRef = useRef('');
|
||||
|
||||
// 2FA状态更新辅助函数
|
||||
const updateTwoFAState = (updates) => {
|
||||
@@ -634,6 +668,10 @@ const EditChannelModal = (props) => {
|
||||
data.disable_store = parsedSettings.disable_store || false;
|
||||
data.allow_safety_identifier =
|
||||
parsedSettings.allow_safety_identifier || false;
|
||||
data.allow_include_obfuscation =
|
||||
parsedSettings.allow_include_obfuscation || false;
|
||||
data.allow_inference_geo =
|
||||
parsedSettings.allow_inference_geo || false;
|
||||
data.claude_beta_query = parsedSettings.claude_beta_query || false;
|
||||
} catch (error) {
|
||||
console.error('解析其他设置失败:', error);
|
||||
@@ -645,6 +683,8 @@ const EditChannelModal = (props) => {
|
||||
data.allow_service_tier = false;
|
||||
data.disable_store = false;
|
||||
data.allow_safety_identifier = false;
|
||||
data.allow_include_obfuscation = false;
|
||||
data.allow_inference_geo = false;
|
||||
data.claude_beta_query = false;
|
||||
}
|
||||
} else {
|
||||
@@ -655,6 +695,8 @@ const EditChannelModal = (props) => {
|
||||
data.allow_service_tier = false;
|
||||
data.disable_store = false;
|
||||
data.allow_safety_identifier = false;
|
||||
data.allow_include_obfuscation = false;
|
||||
data.allow_inference_geo = false;
|
||||
data.claude_beta_query = false;
|
||||
}
|
||||
|
||||
@@ -691,6 +733,7 @@ const EditChannelModal = (props) => {
|
||||
.map((model) => (model || '').trim())
|
||||
.filter(Boolean);
|
||||
initialModelMappingRef.current = data.model_mapping || '';
|
||||
initialStatusCodeMappingRef.current = data.status_code_mapping || '';
|
||||
|
||||
let parsedIonet = null;
|
||||
if (data.other_info) {
|
||||
@@ -996,6 +1039,7 @@ const EditChannelModal = (props) => {
|
||||
}, [inputs]);
|
||||
|
||||
useEffect(() => {
|
||||
setModelSearchValue('');
|
||||
if (props.visible) {
|
||||
if (isEdit) {
|
||||
loadChannel();
|
||||
@@ -1017,11 +1061,22 @@ const EditChannelModal = (props) => {
|
||||
if (!isEdit) {
|
||||
initialModelsRef.current = [];
|
||||
initialModelMappingRef.current = '';
|
||||
initialStatusCodeMappingRef.current = '';
|
||||
}
|
||||
}, [isEdit, props.visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (statusCodeRiskConfirmResolverRef.current) {
|
||||
statusCodeRiskConfirmResolverRef.current(false);
|
||||
statusCodeRiskConfirmResolverRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 统一的模态框重置函数
|
||||
const resetModalState = () => {
|
||||
resolveStatusCodeRiskConfirm(false);
|
||||
formApiRef.current?.reset();
|
||||
// 重置渠道设置状态
|
||||
setChannelSettings({
|
||||
@@ -1039,6 +1094,7 @@ const EditChannelModal = (props) => {
|
||||
// 重置豆包隐藏入口状态
|
||||
setDoubaoApiEditUnlocked(false);
|
||||
doubaoApiClickCountRef.current = 0;
|
||||
setModelSearchValue('');
|
||||
// 清空表单中的key_mode字段
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key_mode', undefined);
|
||||
@@ -1151,6 +1207,22 @@ const EditChannelModal = (props) => {
|
||||
});
|
||||
});
|
||||
|
||||
const resolveStatusCodeRiskConfirm = (confirmed) => {
|
||||
setStatusCodeRiskConfirmVisible(false);
|
||||
setStatusCodeRiskDetailItems([]);
|
||||
if (statusCodeRiskConfirmResolverRef.current) {
|
||||
statusCodeRiskConfirmResolverRef.current(confirmed);
|
||||
statusCodeRiskConfirmResolverRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmStatusCodeRisk = (detailItems) =>
|
||||
new Promise((resolve) => {
|
||||
statusCodeRiskConfirmResolverRef.current = resolve;
|
||||
setStatusCodeRiskDetailItems(detailItems);
|
||||
setStatusCodeRiskConfirmVisible(true);
|
||||
});
|
||||
|
||||
const hasModelConfigChanged = (normalizedModels, modelMappingStr) => {
|
||||
if (!isEdit) return true;
|
||||
const initialModels = initialModelsRef.current;
|
||||
@@ -1340,6 +1412,27 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidStatusCodeEntries = collectInvalidStatusCodeEntries(
|
||||
localInputs.status_code_mapping,
|
||||
);
|
||||
if (invalidStatusCodeEntries.length > 0) {
|
||||
showError(
|
||||
`${t('状态码复写包含无效的状态码')}: ${invalidStatusCodeEntries.join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const riskyStatusCodeRedirects = collectNewDisallowedStatusCodeRedirects(
|
||||
initialStatusCodeMappingRef.current,
|
||||
localInputs.status_code_mapping,
|
||||
);
|
||||
if (riskyStatusCodeRedirects.length > 0) {
|
||||
const confirmed = await confirmStatusCodeRisk(riskyStatusCodeRedirects);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
|
||||
localInputs.base_url = localInputs.base_url.slice(
|
||||
0,
|
||||
@@ -1392,13 +1485,16 @@ const EditChannelModal = (props) => {
|
||||
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
|
||||
if (localInputs.type === 1 || localInputs.type === 14) {
|
||||
settings.allow_service_tier = localInputs.allow_service_tier === true;
|
||||
// 仅 OpenAI 渠道需要 store 和 safety_identifier
|
||||
// 仅 OpenAI 渠道需要 store / safety_identifier / include_obfuscation
|
||||
if (localInputs.type === 1) {
|
||||
settings.disable_store = localInputs.disable_store === true;
|
||||
settings.allow_safety_identifier =
|
||||
localInputs.allow_safety_identifier === true;
|
||||
settings.allow_include_obfuscation =
|
||||
localInputs.allow_include_obfuscation === true;
|
||||
}
|
||||
if (localInputs.type === 14) {
|
||||
settings.allow_inference_geo = localInputs.allow_inference_geo === true;
|
||||
settings.claude_beta_query = localInputs.claude_beta_query === true;
|
||||
}
|
||||
}
|
||||
@@ -1421,6 +1517,8 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.allow_service_tier;
|
||||
delete localInputs.disable_store;
|
||||
delete localInputs.allow_safety_identifier;
|
||||
delete localInputs.allow_include_obfuscation;
|
||||
delete localInputs.allow_inference_geo;
|
||||
delete localInputs.claude_beta_query;
|
||||
|
||||
let res;
|
||||
@@ -2739,9 +2837,18 @@ const EditChannelModal = (props) => {
|
||||
rules={[{ required: true, message: t('请选择模型') }]}
|
||||
multiple
|
||||
filter={selectFilter}
|
||||
allowCreate
|
||||
autoClearSearchValue={false}
|
||||
searchPosition='dropdown'
|
||||
optionList={modelOptions}
|
||||
onSearch={(value) => setModelSearchValue(value)}
|
||||
innerBottomSlot={
|
||||
modelSearchHintText ? (
|
||||
<Text className='px-3 py-2 block text-xs !text-semi-color-text-2'>
|
||||
{modelSearchHintText}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
renderSelectedItem={(optionNode) => {
|
||||
@@ -3271,6 +3378,24 @@ const EditChannelModal = (props) => {
|
||||
'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form.Switch
|
||||
field='allow_include_obfuscation'
|
||||
label={t(
|
||||
'允许 stream_options.include_obfuscation 透传',
|
||||
)}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange(
|
||||
'allow_include_obfuscation',
|
||||
value,
|
||||
)
|
||||
}
|
||||
extraText={t(
|
||||
'include_obfuscation 用于控制 Responses 流混淆字段。默认关闭以避免客户端关闭该安全保护',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3296,6 +3421,22 @@ const EditChannelModal = (props) => {
|
||||
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form.Switch
|
||||
field='allow_inference_geo'
|
||||
label={t('允许 inference_geo 透传')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange(
|
||||
'allow_inference_geo',
|
||||
value,
|
||||
)
|
||||
}
|
||||
extraText={t(
|
||||
'inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -3440,6 +3581,12 @@ const EditChannelModal = (props) => {
|
||||
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
||||
/>
|
||||
</SideSheet>
|
||||
<StatusCodeRiskGuardModal
|
||||
visible={statusCodeRiskConfirmVisible}
|
||||
detailItems={statusCodeRiskDetailItems}
|
||||
onCancel={() => resolveStatusCodeRiskConfirm(false)}
|
||||
onConfirm={() => resolveStatusCodeRiskConfirm(true)}
|
||||
/>
|
||||
{/* 使用通用安全验证模态框 */}
|
||||
<SecureVerificationModal
|
||||
visible={isModalVisible}
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
@@ -64,6 +64,7 @@ const EditTagModal = (props) => {
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [modelSearchValue, setModelSearchValue] = useState('');
|
||||
const originInputs = {
|
||||
tag: '',
|
||||
new_tag: null,
|
||||
@@ -74,6 +75,25 @@ const EditTagModal = (props) => {
|
||||
header_override: null,
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const modelSearchMatchedCount = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword) {
|
||||
return modelOptions.length;
|
||||
}
|
||||
return modelOptions.reduce(
|
||||
(count, option) => count + (selectFilter(keyword, option) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}, [modelOptions, modelSearchValue]);
|
||||
const modelSearchHintText = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword || modelSearchMatchedCount !== 0) {
|
||||
return '';
|
||||
}
|
||||
return t('未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加', {
|
||||
name: keyword,
|
||||
});
|
||||
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
||||
const formApiRef = useRef(null);
|
||||
const getInitValues = () => ({ ...originInputs });
|
||||
|
||||
@@ -292,6 +312,7 @@ const EditTagModal = (props) => {
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
fetchTagModels().then();
|
||||
setModelSearchValue('');
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
@@ -461,9 +482,18 @@ const EditTagModal = (props) => {
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
multiple
|
||||
filter={selectFilter}
|
||||
allowCreate
|
||||
autoClearSearchValue={false}
|
||||
searchPosition='dropdown'
|
||||
optionList={modelOptions}
|
||||
onSearch={(value) => setModelSearchValue(value)}
|
||||
innerBottomSlot={
|
||||
modelSearchHintText ? (
|
||||
<Text className='px-3 py-2 block text-xs !text-semi-color-text-2'>
|
||||
{modelSearchHintText}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RiskAcknowledgementModal from '../../../common/modals/RiskAcknowledgementModal';
|
||||
import {
|
||||
STATUS_CODE_RISK_I18N_KEYS,
|
||||
STATUS_CODE_RISK_CHECKLIST_KEYS,
|
||||
} from './statusCodeRiskGuard';
|
||||
|
||||
const StatusCodeRiskGuardModal = React.memo(function StatusCodeRiskGuardModal({
|
||||
visible,
|
||||
detailItems,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const checklist = useMemo(
|
||||
() => STATUS_CODE_RISK_CHECKLIST_KEYS.map((item) => t(item)),
|
||||
[t, i18n.language],
|
||||
);
|
||||
|
||||
return (
|
||||
<RiskAcknowledgementModal
|
||||
visible={visible}
|
||||
title={t(STATUS_CODE_RISK_I18N_KEYS.title)}
|
||||
markdownContent={t(STATUS_CODE_RISK_I18N_KEYS.markdown)}
|
||||
detailTitle={t(STATUS_CODE_RISK_I18N_KEYS.detailTitle)}
|
||||
detailItems={detailItems}
|
||||
checklist={checklist}
|
||||
inputPrompt={t(STATUS_CODE_RISK_I18N_KEYS.inputPrompt)}
|
||||
requiredText={t(STATUS_CODE_RISK_I18N_KEYS.confirmText)}
|
||||
inputPlaceholder={t(STATUS_CODE_RISK_I18N_KEYS.inputPlaceholder)}
|
||||
mismatchText={t(STATUS_CODE_RISK_I18N_KEYS.mismatchText)}
|
||||
cancelText={t('取消')}
|
||||
confirmText={t(STATUS_CODE_RISK_I18N_KEYS.confirmButton)}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default StatusCodeRiskGuardModal;
|
||||
132
web/src/components/table/channels/modals/statusCodeRiskGuard.js
Normal file
132
web/src/components/table/channels/modals/statusCodeRiskGuard.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524]);
|
||||
|
||||
export const STATUS_CODE_RISK_I18N_KEYS = {
|
||||
title: '高危操作确认',
|
||||
detailTitle: '检测到以下高危状态码重定向规则',
|
||||
inputPrompt: '操作确认',
|
||||
confirmButton: '我确认开启高危重试',
|
||||
markdown: '高危状态码重试风险告知与免责声明Markdown',
|
||||
confirmText: '高危状态码重试风险确认输入文本',
|
||||
inputPlaceholder: '高危状态码重试风险输入框占位文案',
|
||||
mismatchText: '高危状态码重试风险输入不匹配提示',
|
||||
};
|
||||
|
||||
export const STATUS_CODE_RISK_CHECKLIST_KEYS = [
|
||||
'高危状态码重试风险确认项1',
|
||||
'高危状态码重试风险确认项2',
|
||||
'高危状态码重试风险确认项3',
|
||||
'高危状态码重试风险确认项4',
|
||||
];
|
||||
|
||||
function parseStatusCodeKey(rawKey) {
|
||||
if (typeof rawKey !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = rawKey.trim();
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return Number.parseInt(normalized, 10);
|
||||
}
|
||||
|
||||
function parseStatusCodeMappingTarget(rawValue) {
|
||||
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
|
||||
return rawValue >= 100 && rawValue <= 599 ? rawValue : null;
|
||||
}
|
||||
if (typeof rawValue === 'string') {
|
||||
const normalized = rawValue.trim();
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
const code = Number.parseInt(normalized, 10);
|
||||
return code >= 100 && code <= 599 ? code : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function collectInvalidStatusCodeEntries(statusCodeMappingStr) {
|
||||
if (
|
||||
typeof statusCodeMappingStr !== 'string' ||
|
||||
statusCodeMappingStr.trim() === ''
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const invalid = [];
|
||||
for (const [rawKey, rawValue] of Object.entries(parsed)) {
|
||||
const fromCode = parseStatusCodeKey(rawKey);
|
||||
const toCode = parseStatusCodeMappingTarget(rawValue);
|
||||
if (fromCode === null || toCode === null) {
|
||||
invalid.push(`${rawKey} → ${rawValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
export function collectDisallowedStatusCodeRedirects(statusCodeMappingStr) {
|
||||
if (
|
||||
typeof statusCodeMappingStr !== 'string' ||
|
||||
statusCodeMappingStr.trim() === ''
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const riskyMappings = [];
|
||||
Object.entries(parsed).forEach(([rawFrom, rawTo]) => {
|
||||
const fromCode = parseStatusCodeKey(rawFrom);
|
||||
const toCode = parseStatusCodeMappingTarget(rawTo);
|
||||
if (fromCode === null || toCode === null) {
|
||||
return;
|
||||
}
|
||||
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) {
|
||||
return;
|
||||
}
|
||||
if (fromCode === toCode) {
|
||||
return;
|
||||
}
|
||||
riskyMappings.push(`${fromCode} -> ${toCode}`);
|
||||
});
|
||||
|
||||
return Array.from(new Set(riskyMappings)).sort();
|
||||
}
|
||||
|
||||
export function collectNewDisallowedStatusCodeRedirects(
|
||||
originalStatusCodeMappingStr,
|
||||
currentStatusCodeMappingStr,
|
||||
) {
|
||||
const currentRisky = collectDisallowedStatusCodeRedirects(
|
||||
currentStatusCodeMappingStr,
|
||||
);
|
||||
if (currentRisky.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const originalRiskySet = new Set(
|
||||
collectDisallowedStatusCodeRedirects(originalStatusCodeMappingStr),
|
||||
);
|
||||
|
||||
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping));
|
||||
}
|
||||
@@ -240,6 +240,7 @@ export const getTaskLogsColumns = ({
|
||||
openContentModal,
|
||||
isAdminUser,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -386,6 +387,26 @@ export const getTaskLogsColumns = ({
|
||||
dataIndex: 'fail_reason',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
// Suno audio preview
|
||||
const isSunoSuccess =
|
||||
record.platform === 'suno' &&
|
||||
record.status === 'SUCCESS' &&
|
||||
Array.isArray(record.data) &&
|
||||
record.data.some((c) => c.audio_url);
|
||||
if (isSunoSuccess) {
|
||||
return (
|
||||
<a
|
||||
href='#'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openAudioModal(record.data);
|
||||
}}
|
||||
>
|
||||
{t('点击预览音乐')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// 视频预览:优先使用 result_url,兼容旧数据 fail_reason 中的 URL
|
||||
const isVideoTask =
|
||||
record.action === TASK_ACTION_GENERATE ||
|
||||
|
||||
@@ -40,6 +40,7 @@ const TaskLogsTable = (taskLogsData) => {
|
||||
copyText,
|
||||
openContentModal,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
showUserInfoFunc,
|
||||
isAdminUser,
|
||||
t,
|
||||
@@ -54,10 +55,11 @@ const TaskLogsTable = (taskLogsData) => {
|
||||
copyText,
|
||||
openContentModal,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
showUserInfoFunc,
|
||||
isAdminUser,
|
||||
});
|
||||
}, [t, COLUMN_KEYS, copyText, openContentModal, openVideoModal, showUserInfoFunc, isAdminUser]);
|
||||
}, [t, COLUMN_KEYS, copyText, openContentModal, openVideoModal, openAudioModal, showUserInfoFunc, isAdminUser]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import TaskLogsActions from './TaskLogsActions';
|
||||
import TaskLogsFilters from './TaskLogsFilters';
|
||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||
import ContentModal from './modals/ContentModal';
|
||||
import AudioPreviewModal from './modals/AudioPreviewModal';
|
||||
import { useTaskLogsData } from '../../../hooks/task-logs/useTaskLogsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
@@ -45,6 +46,11 @@ const TaskLogsPage = () => {
|
||||
modalContent={taskLogsData.videoUrl}
|
||||
isVideo={true}
|
||||
/>
|
||||
<AudioPreviewModal
|
||||
isModalOpen={taskLogsData.isAudioModalOpen}
|
||||
setIsModalOpen={taskLogsData.setIsAudioModalOpen}
|
||||
audioClips={taskLogsData.audioClips}
|
||||
/>
|
||||
|
||||
<Layout>
|
||||
<CardPro
|
||||
|
||||
181
web/src/components/table/task-logs/modals/AudioPreviewModal.jsx
Normal file
181
web/src/components/table/task-logs/modals/AudioPreviewModal.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
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, { useState, useRef, useEffect } from 'react';
|
||||
import { Modal, Typography, Tag, Button } from '@douyinfe/semi-ui';
|
||||
import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds || seconds <= 0) return '--:--';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const AudioClipCard = ({ clip }) => {
|
||||
const { t } = useTranslation();
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const audioRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasError(false);
|
||||
}, [clip.audio_url]);
|
||||
|
||||
const title = clip.title || t('未命名');
|
||||
const tags = clip.tags || clip.metadata?.tags || '';
|
||||
const duration = clip.duration || clip.metadata?.duration;
|
||||
const imageUrl = clip.image_url || clip.image_large_url;
|
||||
const audioUrl = clip.audio_url;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
}}
|
||||
>
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '8px',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<Text strong ellipsis={{ showTooltip: true }} style={{ fontSize: 15 }}>
|
||||
{title}
|
||||
</Text>
|
||||
{duration > 0 && (
|
||||
<Tag size='small' color='grey' shape='circle'>
|
||||
{formatDuration(duration)}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tags && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
ellipsis={{ showTooltip: true, rows: 1 }}
|
||||
>
|
||||
{tags}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Text type='warning' size='small'>
|
||||
{t('音频无法播放')}
|
||||
</Text>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<IconExternalOpen />}
|
||||
onClick={() => window.open(audioUrl, '_blank')}
|
||||
>
|
||||
{t('在新标签页中打开')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<IconCopy />}
|
||||
onClick={() => navigator.clipboard.writeText(audioUrl)}
|
||||
>
|
||||
{t('复制链接')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
controls
|
||||
preload='none'
|
||||
onError={() => setHasError(true)}
|
||||
style={{ width: '100%', height: 36 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioPreviewModal = ({ isModalOpen, setIsModalOpen, audioClips }) => {
|
||||
const { t } = useTranslation();
|
||||
const clips = Array.isArray(audioClips) ? audioClips : [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('音乐预览')}
|
||||
visible={isModalOpen}
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
footer={null}
|
||||
bodyStyle={{
|
||||
maxHeight: '70vh',
|
||||
overflow: 'auto',
|
||||
padding: '16px',
|
||||
}}
|
||||
width={560}
|
||||
>
|
||||
{clips.length === 0 ? (
|
||||
<Text type='tertiary'>{t('无')}</Text>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{clips.map((clip, idx) => (
|
||||
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPreviewModal;
|
||||
@@ -39,6 +39,21 @@ function formatTokenRate(n, d) {
|
||||
return `${r.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatCachedTokenRate(cachedTokens, promptTokens, mode) {
|
||||
if (mode === 'cached_over_prompt_plus_cached') {
|
||||
const denominator = Number(promptTokens || 0) + Number(cachedTokens || 0);
|
||||
return formatTokenRate(cachedTokens, denominator);
|
||||
}
|
||||
if (mode === 'cached_over_prompt') {
|
||||
return formatTokenRate(cachedTokens, promptTokens);
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function hasTextValue(value) {
|
||||
return typeof value === 'string' && value.trim() !== '';
|
||||
}
|
||||
|
||||
const ChannelAffinityUsageCacheModal = ({
|
||||
t,
|
||||
showChannelAffinityUsageCacheModal,
|
||||
@@ -107,7 +122,7 @@ const ChannelAffinityUsageCacheModal = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const { rows, supportsTokenStats } = useMemo(() => {
|
||||
const s = stats || {};
|
||||
const hit = Number(s.hit || 0);
|
||||
const total = Number(s.total || 0);
|
||||
@@ -118,48 +133,62 @@ const ChannelAffinityUsageCacheModal = ({
|
||||
const totalTokens = Number(s.total_tokens || 0);
|
||||
const cachedTokens = Number(s.cached_tokens || 0);
|
||||
const promptCacheHitTokens = Number(s.prompt_cache_hit_tokens || 0);
|
||||
const cachedTokenRateMode = String(s.cached_token_rate_mode || '').trim();
|
||||
const supportsTokenStats =
|
||||
cachedTokenRateMode === 'cached_over_prompt' ||
|
||||
cachedTokenRateMode === 'cached_over_prompt_plus_cached' ||
|
||||
cachedTokenRateMode === 'mixed';
|
||||
|
||||
return [
|
||||
{ key: t('规则'), value: s.rule_name || params.rule_name || '-' },
|
||||
{ key: t('分组'), value: s.using_group || params.using_group || '-' },
|
||||
{
|
||||
key: t('Key 摘要'),
|
||||
value: params.key_hint || '-',
|
||||
},
|
||||
{
|
||||
key: t('Key 指纹'),
|
||||
value: s.key_fp || params.key_fp || '-',
|
||||
},
|
||||
{ key: t('TTL(秒)'), value: windowSeconds > 0 ? windowSeconds : '-' },
|
||||
{
|
||||
key: t('命中率'),
|
||||
value: `${hit}/${total} (${formatRate(hit, total)})`,
|
||||
},
|
||||
{
|
||||
key: t('Prompt tokens'),
|
||||
value: promptTokens,
|
||||
},
|
||||
{
|
||||
key: t('Cached tokens'),
|
||||
value: `${cachedTokens} (${formatTokenRate(cachedTokens, promptTokens)})`,
|
||||
},
|
||||
{
|
||||
key: t('Prompt cache hit tokens'),
|
||||
value: promptCacheHitTokens,
|
||||
},
|
||||
{
|
||||
key: t('Completion tokens'),
|
||||
value: completionTokens,
|
||||
},
|
||||
{
|
||||
key: t('Total tokens'),
|
||||
value: totalTokens,
|
||||
},
|
||||
{
|
||||
key: t('最近一次'),
|
||||
value: lastSeenAt > 0 ? timestamp2string(lastSeenAt) : '-',
|
||||
},
|
||||
];
|
||||
const data = [];
|
||||
const ruleName = String(s.rule_name || params.rule_name || '').trim();
|
||||
const usingGroup = String(s.using_group || params.using_group || '').trim();
|
||||
const keyHint = String(params.key_hint || '').trim();
|
||||
const keyFp = String(s.key_fp || params.key_fp || '').trim();
|
||||
|
||||
if (hasTextValue(ruleName)) {
|
||||
data.push({ key: t('规则'), value: ruleName });
|
||||
}
|
||||
if (hasTextValue(usingGroup)) {
|
||||
data.push({ key: t('分组'), value: usingGroup });
|
||||
}
|
||||
if (hasTextValue(keyHint)) {
|
||||
data.push({ key: t('Key 摘要'), value: keyHint });
|
||||
}
|
||||
if (hasTextValue(keyFp)) {
|
||||
data.push({ key: t('Key 指纹'), value: keyFp });
|
||||
}
|
||||
if (windowSeconds > 0) {
|
||||
data.push({ key: t('TTL(秒)'), value: windowSeconds });
|
||||
}
|
||||
if (total > 0) {
|
||||
data.push({ key: t('命中率'), value: `${hit}/${total} (${formatRate(hit, total)})` });
|
||||
}
|
||||
if (lastSeenAt > 0) {
|
||||
data.push({ key: t('最近一次'), value: timestamp2string(lastSeenAt) });
|
||||
}
|
||||
|
||||
if (supportsTokenStats) {
|
||||
if (promptTokens > 0) {
|
||||
data.push({ key: t('Prompt tokens'), value: promptTokens });
|
||||
}
|
||||
if (promptTokens > 0 || cachedTokens > 0) {
|
||||
data.push({
|
||||
key: t('Cached tokens'),
|
||||
value: `${cachedTokens} (${formatCachedTokenRate(cachedTokens, promptTokens, cachedTokenRateMode)})`,
|
||||
});
|
||||
}
|
||||
if (promptCacheHitTokens > 0) {
|
||||
data.push({ key: t('Prompt cache hit tokens'), value: promptCacheHitTokens });
|
||||
}
|
||||
if (completionTokens > 0) {
|
||||
data.push({ key: t('Completion tokens'), value: completionTokens });
|
||||
}
|
||||
if (totalTokens > 0) {
|
||||
data.push({ key: t('Total tokens'), value: totalTokens });
|
||||
}
|
||||
}
|
||||
|
||||
return { rows: data, supportsTokenStats };
|
||||
}, [stats, params, t]);
|
||||
|
||||
return (
|
||||
@@ -179,15 +208,27 @@ const ChannelAffinityUsageCacheModal = ({
|
||||
{t(
|
||||
'命中判定:usage 中存在 cached tokens(例如 cached_tokens/prompt_cache_hit_tokens)即视为命中。',
|
||||
)}
|
||||
{' '}
|
||||
{t(
|
||||
'Cached tokens 占比口径由后端返回:Claude 语义按 cached/(prompt+cached),其余按 cached/prompt。',
|
||||
)}
|
||||
{' '}
|
||||
{t('当前仅 OpenAI / Claude 语义支持缓存 token 统计,其他通道将隐藏 token 相关字段。')}
|
||||
{stats && !supportsTokenStats ? (
|
||||
<>
|
||||
{' '}
|
||||
{t('该记录不包含可用的 token 统计口径。')}
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
</div>
|
||||
<Spin spinning={loading} tip={t('加载中...')}>
|
||||
{stats ? (
|
||||
{stats && rows.length > 0 ? (
|
||||
<Descriptions data={rows} />
|
||||
) : (
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
<Text type='tertiary' size='small'>
|
||||
{loading ? t('加载中...') : t('暂无数据')}
|
||||
{loading ? t('加载中...') : t('暂无可展示数据')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -56,6 +55,7 @@ import {
|
||||
IconUserGroup,
|
||||
IconPlus,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import UserBindingManagementModal from './UserBindingManagementModal';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -68,6 +68,7 @@ const EditUserModal = (props) => {
|
||||
const [addAmountLocal, setAddAmountLocal] = useState('');
|
||||
const isMobile = useIsMobile();
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [bindingModalVisible, setBindingModalVisible] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const isEdit = Boolean(userId);
|
||||
@@ -81,6 +82,7 @@ const EditUserModal = (props) => {
|
||||
discord_id: '',
|
||||
wechat_id: '',
|
||||
telegram_id: '',
|
||||
linux_do_id: '',
|
||||
email: '',
|
||||
quota: 0,
|
||||
group: 'default',
|
||||
@@ -115,8 +117,17 @@ const EditUserModal = (props) => {
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
if (userId) fetchGroups();
|
||||
setBindingModalVisible(false);
|
||||
}, [props.editingUser.id]);
|
||||
|
||||
const openBindingModal = () => {
|
||||
setBindingModalVisible(true);
|
||||
};
|
||||
|
||||
const closeBindingModal = () => {
|
||||
setBindingModalVisible(false);
|
||||
};
|
||||
|
||||
/* ----------------------- submit ----------------------- */
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
@@ -196,7 +207,7 @@ const EditUserModal = (props) => {
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
<div className='p-2 space-y-3'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
@@ -316,56 +327,51 @@ const EditUserModal = (props) => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 绑定信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('绑定信息')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('第三方账户绑定状态(只读)')}
|
||||
{/* 绑定信息入口 */}
|
||||
{userId && (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center min-w-0'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div className='min-w-0'>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('绑定信息')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('管理用户已绑定的第三方账户,支持筛选与解绑')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={openBindingModal}
|
||||
>
|
||||
{t('管理绑定')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
{[
|
||||
'github_id',
|
||||
'discord_id',
|
||||
'oidc_id',
|
||||
'wechat_id',
|
||||
'email',
|
||||
'telegram_id',
|
||||
].map((field) => (
|
||||
<Col span={24} key={field}>
|
||||
<Form.Input
|
||||
field={field}
|
||||
label={t(
|
||||
`已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`,
|
||||
)}
|
||||
readonly
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
|
||||
<UserBindingManagementModal
|
||||
visible={bindingModalVisible}
|
||||
onCancel={closeBindingModal}
|
||||
userId={userId}
|
||||
isMobile={isMobile}
|
||||
formApiRef={formApiRef}
|
||||
/>
|
||||
|
||||
{/* 添加额度模态框 */}
|
||||
<Modal
|
||||
centered
|
||||
@@ -401,7 +407,10 @@ const EditUserModal = (props) => {
|
||||
<div className='mb-3'>
|
||||
<div className='mb-1'>
|
||||
<Text size='small'>{t('金额')}</Text>
|
||||
<Text size='small' type='tertiary'> ({t('仅用于换算,实际保存的是额度')})</Text>
|
||||
<Text size='small' type='tertiary'>
|
||||
{' '}
|
||||
({t('仅用于换算,实际保存的是额度')})
|
||||
</Text>
|
||||
</div>
|
||||
<InputNumber
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
@@ -411,7 +420,9 @@ const EditUserModal = (props) => {
|
||||
onChange={(val) => {
|
||||
setAddAmountLocal(val);
|
||||
setAddQuotaLocal(
|
||||
val != null && val !== '' ? displayAmountToQuota(Math.abs(val)) * Math.sign(val) : '',
|
||||
val != null && val !== ''
|
||||
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
@@ -430,7 +441,11 @@ const EditUserModal = (props) => {
|
||||
setAddQuotaLocal(val);
|
||||
setAddAmountLocal(
|
||||
val != null && val !== ''
|
||||
? Number((quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)).toFixed(2))
|
||||
? Number(
|
||||
(
|
||||
quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
|
||||
).toFixed(2),
|
||||
)
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
/*
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
getOAuthProviderIcon,
|
||||
} from '../../../../helpers';
|
||||
import {
|
||||
Modal,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Checkbox,
|
||||
Tag,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconLink,
|
||||
IconMail,
|
||||
IconDelete,
|
||||
IconGithubLogo,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { SiDiscord, SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const UserBindingManagementModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
userId,
|
||||
isMobile,
|
||||
formApiRef,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [bindingLoading, setBindingLoading] = React.useState(false);
|
||||
const [showBoundOnly, setShowBoundOnly] = React.useState(true);
|
||||
const [statusInfo, setStatusInfo] = React.useState({});
|
||||
const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
|
||||
const [builtInBindings, setBuiltInBindings] = React.useState({});
|
||||
const [bindingActionLoading, setBindingActionLoading] = React.useState({});
|
||||
|
||||
const loadBindingData = React.useCallback(async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setBindingLoading(true);
|
||||
try {
|
||||
const [statusRes, customBindingRes, userRes] = await Promise.all([
|
||||
API.get('/api/status'),
|
||||
API.get(`/api/user/${userId}/oauth/bindings`),
|
||||
API.get(`/api/user/${userId}`),
|
||||
]);
|
||||
|
||||
if (statusRes.data?.success) {
|
||||
setStatusInfo(statusRes.data.data || {});
|
||||
} else {
|
||||
showError(statusRes.data?.message || t('操作失败'));
|
||||
}
|
||||
|
||||
if (customBindingRes.data?.success) {
|
||||
setCustomOAuthBindings(customBindingRes.data.data || []);
|
||||
} else {
|
||||
showError(customBindingRes.data?.message || t('操作失败'));
|
||||
}
|
||||
|
||||
if (userRes.data?.success) {
|
||||
const userData = userRes.data.data || {};
|
||||
setBuiltInBindings({
|
||||
email: userData.email || '',
|
||||
github_id: userData.github_id || '',
|
||||
discord_id: userData.discord_id || '',
|
||||
oidc_id: userData.oidc_id || '',
|
||||
wechat_id: userData.wechat_id || '',
|
||||
telegram_id: userData.telegram_id || '',
|
||||
linux_do_id: userData.linux_do_id || '',
|
||||
});
|
||||
} else {
|
||||
showError(userRes.data?.message || t('操作失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
error.response?.data?.message || error.message || t('操作失败'),
|
||||
);
|
||||
} finally {
|
||||
setBindingLoading(false);
|
||||
}
|
||||
}, [t, userId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!visible) return;
|
||||
setShowBoundOnly(true);
|
||||
setBindingActionLoading({});
|
||||
loadBindingData();
|
||||
}, [visible, loadBindingData]);
|
||||
|
||||
const setBindingLoadingState = (key, value) => {
|
||||
setBindingActionLoading((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleUnbindBuiltInAccount = (bindingItem) => {
|
||||
if (!userId) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: t('确认解绑'),
|
||||
content: t('确定要解绑 {{name}} 吗?', { name: bindingItem.name }),
|
||||
okText: t('确认'),
|
||||
cancelText: t('取消'),
|
||||
onOk: async () => {
|
||||
const loadingKey = `builtin-${bindingItem.key}`;
|
||||
setBindingLoadingState(loadingKey, true);
|
||||
try {
|
||||
const res = await API.delete(
|
||||
`/api/user/${userId}/bindings/${bindingItem.key}`,
|
||||
);
|
||||
if (!res.data?.success) {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
return;
|
||||
}
|
||||
setBuiltInBindings((prev) => ({
|
||||
...prev,
|
||||
[bindingItem.field]: '',
|
||||
}));
|
||||
formApiRef.current?.setValue(bindingItem.field, '');
|
||||
showSuccess(t('解绑成功'));
|
||||
} catch (error) {
|
||||
showError(
|
||||
error.response?.data?.message || error.message || t('操作失败'),
|
||||
);
|
||||
} finally {
|
||||
setBindingLoadingState(loadingKey, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnbindCustomOAuthAccount = (provider) => {
|
||||
if (!userId) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: t('确认解绑'),
|
||||
content: t('确定要解绑 {{name}} 吗?', { name: provider.name }),
|
||||
okText: t('确认'),
|
||||
cancelText: t('取消'),
|
||||
onOk: async () => {
|
||||
const loadingKey = `custom-${provider.id}`;
|
||||
setBindingLoadingState(loadingKey, true);
|
||||
try {
|
||||
const res = await API.delete(
|
||||
`/api/user/${userId}/oauth/bindings/${provider.id}`,
|
||||
);
|
||||
if (!res.data?.success) {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
return;
|
||||
}
|
||||
setCustomOAuthBindings((prev) =>
|
||||
prev.filter(
|
||||
(item) => Number(item.provider_id) !== Number(provider.id),
|
||||
),
|
||||
);
|
||||
showSuccess(t('解绑成功'));
|
||||
} catch (error) {
|
||||
showError(
|
||||
error.response?.data?.message || error.message || t('操作失败'),
|
||||
);
|
||||
} finally {
|
||||
setBindingLoadingState(loadingKey, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const currentValues = formApiRef.current?.getValues?.() || {};
|
||||
const getBuiltInBindingValue = (field) =>
|
||||
builtInBindings[field] || currentValues[field] || '';
|
||||
|
||||
const builtInBindingItems = [
|
||||
{
|
||||
key: 'email',
|
||||
field: 'email',
|
||||
name: t('邮箱'),
|
||||
enabled: true,
|
||||
value: getBuiltInBindingValue('email'),
|
||||
icon: (
|
||||
<IconMail
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'github',
|
||||
field: 'github_id',
|
||||
name: 'GitHub',
|
||||
enabled: Boolean(statusInfo.github_oauth),
|
||||
value: getBuiltInBindingValue('github_id'),
|
||||
icon: (
|
||||
<IconGithubLogo
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'discord',
|
||||
field: 'discord_id',
|
||||
name: 'Discord',
|
||||
enabled: Boolean(statusInfo.discord_oauth),
|
||||
value: getBuiltInBindingValue('discord_id'),
|
||||
icon: (
|
||||
<SiDiscord size={20} className='text-slate-600 dark:text-slate-300' />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'oidc',
|
||||
field: 'oidc_id',
|
||||
name: 'OIDC',
|
||||
enabled: Boolean(statusInfo.oidc_enabled),
|
||||
value: getBuiltInBindingValue('oidc_id'),
|
||||
icon: (
|
||||
<IconLink
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wechat',
|
||||
field: 'wechat_id',
|
||||
name: t('微信'),
|
||||
enabled: Boolean(statusInfo.wechat_login),
|
||||
value: getBuiltInBindingValue('wechat_id'),
|
||||
icon: (
|
||||
<SiWechat size={20} className='text-slate-600 dark:text-slate-300' />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'telegram',
|
||||
field: 'telegram_id',
|
||||
name: 'Telegram',
|
||||
enabled: Boolean(statusInfo.telegram_oauth),
|
||||
value: getBuiltInBindingValue('telegram_id'),
|
||||
icon: (
|
||||
<SiTelegram size={20} className='text-slate-600 dark:text-slate-300' />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'linuxdo',
|
||||
field: 'linux_do_id',
|
||||
name: 'LinuxDO',
|
||||
enabled: Boolean(statusInfo.linuxdo_oauth),
|
||||
value: getBuiltInBindingValue('linux_do_id'),
|
||||
icon: (
|
||||
<SiLinux size={20} className='text-slate-600 dark:text-slate-300' />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const customBindingMap = new Map(
|
||||
customOAuthBindings.map((item) => [Number(item.provider_id), item]),
|
||||
);
|
||||
|
||||
const customProviderMap = new Map(
|
||||
(statusInfo.custom_oauth_providers || []).map((provider) => [
|
||||
Number(provider.id),
|
||||
provider,
|
||||
]),
|
||||
);
|
||||
|
||||
customOAuthBindings.forEach((binding) => {
|
||||
if (!customProviderMap.has(Number(binding.provider_id))) {
|
||||
customProviderMap.set(Number(binding.provider_id), {
|
||||
id: binding.provider_id,
|
||||
name: binding.provider_name,
|
||||
icon: binding.provider_icon,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const customBindingItems = Array.from(customProviderMap.values()).map(
|
||||
(provider) => {
|
||||
const binding = customBindingMap.get(Number(provider.id));
|
||||
return {
|
||||
key: `custom-${provider.id}`,
|
||||
providerId: provider.id,
|
||||
name: provider.name,
|
||||
enabled: true,
|
||||
value: binding?.provider_user_id || '',
|
||||
icon: getOAuthProviderIcon(
|
||||
provider.icon || binding?.provider_icon || '',
|
||||
20,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const allBindingItems = [
|
||||
...builtInBindingItems.map((item) => ({ ...item, type: 'builtin' })),
|
||||
...customBindingItems.map((item) => ({ ...item, type: 'custom' })),
|
||||
];
|
||||
|
||||
const boundCount = allBindingItems.filter((item) =>
|
||||
Boolean(item.value),
|
||||
).length;
|
||||
|
||||
const visibleBindingItems = showBoundOnly
|
||||
? allBindingItems.filter((item) => Boolean(item.value))
|
||||
: allBindingItems;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={isMobile ? '100%' : 760}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconLink className='mr-2' />
|
||||
{t('账户绑定管理')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Spin spinning={bindingLoading}>
|
||||
<div className='max-h-[68vh] overflow-y-auto pr-1 pb-2'>
|
||||
<div className='flex items-center justify-between mb-4 gap-3 flex-wrap'>
|
||||
<Checkbox
|
||||
checked={showBoundOnly}
|
||||
onChange={(e) => setShowBoundOnly(Boolean(e.target.checked))}
|
||||
>
|
||||
{t('仅显示已绑定')}
|
||||
</Checkbox>
|
||||
<Text type='tertiary'>
|
||||
{t('已绑定')} {boundCount} / {allBindingItems.length}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{visibleBindingItems.length === 0 ? (
|
||||
<Card className='!rounded-xl border-dashed'>
|
||||
<Text type='tertiary'>{t('暂无已绑定项')}</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
||||
{visibleBindingItems.map((item, index) => {
|
||||
const isBound = Boolean(item.value);
|
||||
const loadingKey =
|
||||
item.type === 'builtin'
|
||||
? `builtin-${item.key}`
|
||||
: `custom-${item.providerId}`;
|
||||
const statusText = isBound
|
||||
? item.value
|
||||
: item.enabled
|
||||
? t('未绑定')
|
||||
: t('未启用');
|
||||
const shouldSpanTwoColsOnDesktop =
|
||||
visibleBindingItems.length % 2 === 1 &&
|
||||
index === visibleBindingItems.length - 1;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.key}
|
||||
className={`!rounded-xl ${shouldSpanTwoColsOnDesktop ? 'lg:col-span-2' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3 min-h-[92px]'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='font-medium text-gray-900 flex items-center gap-2'>
|
||||
<span>{item.name}</span>
|
||||
<Tag size='small' color='white'>
|
||||
{item.type === 'builtin'
|
||||
? t('内置')
|
||||
: t('自定义')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
disabled={!isBound}
|
||||
loading={Boolean(bindingActionLoading[loadingKey])}
|
||||
onClick={() => {
|
||||
if (item.type === 'builtin') {
|
||||
handleUnbindBuiltInAccount(item);
|
||||
return;
|
||||
}
|
||||
handleUnbindCustomOAuthAccount({
|
||||
id: item.providerId,
|
||||
name: item.name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('解绑')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBindingManagementModal;
|
||||
17
web/src/hooks/task-logs/useTaskLogsData.js
vendored
17
web/src/hooks/task-logs/useTaskLogsData.js
vendored
@@ -72,6 +72,10 @@ export const useTaskLogsData = () => {
|
||||
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
|
||||
// Audio preview modal state
|
||||
const [isAudioModalOpen, setIsAudioModalOpen] = useState(false);
|
||||
const [audioClips, setAudioClips] = useState([]);
|
||||
|
||||
// User info modal state
|
||||
const [showUserInfo, setShowUserInfoModal] = useState(false);
|
||||
const [userInfoData, setUserInfoData] = useState(null);
|
||||
@@ -277,6 +281,11 @@ export const useTaskLogsData = () => {
|
||||
setIsVideoModalOpen(true);
|
||||
};
|
||||
|
||||
const openAudioModal = (clips) => {
|
||||
setAudioClips(clips);
|
||||
setIsAudioModalOpen(true);
|
||||
};
|
||||
|
||||
// User info function
|
||||
const showUserInfoFunc = async (userId) => {
|
||||
if (!isAdminUser) {
|
||||
@@ -319,6 +328,11 @@ export const useTaskLogsData = () => {
|
||||
setIsVideoModalOpen,
|
||||
videoUrl,
|
||||
|
||||
// Audio preview modal
|
||||
isAudioModalOpen,
|
||||
setIsAudioModalOpen,
|
||||
audioClips,
|
||||
|
||||
// Form state
|
||||
formApi,
|
||||
setFormApi,
|
||||
@@ -351,7 +365,8 @@ export const useTaskLogsData = () => {
|
||||
refresh,
|
||||
copyText,
|
||||
openContentModal,
|
||||
openVideoModal, // 新增
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
enrichLogs,
|
||||
syncPageData,
|
||||
|
||||
|
||||
20
web/src/i18n/locales/en.json
vendored
20
web/src/i18n/locales/en.json
vendored
@@ -1634,10 +1634,14 @@
|
||||
"点击查看差异": "Click to view differences",
|
||||
"点击此处": "click here",
|
||||
"点击预览视频": "Click to preview video",
|
||||
"点击预览音乐": "Click to preview music",
|
||||
"音乐预览": "Music Preview",
|
||||
"音频无法播放": "Audio cannot be played",
|
||||
"点击验证按钮,使用您的生物特征或安全密钥": "Click the verification button and use your biometrics or security key",
|
||||
"版权所有": "All rights reserved",
|
||||
"状态": "Status",
|
||||
"状态码复写": "Status Code Override",
|
||||
"状态码复写包含无效的状态码": "Status code override contains invalid status codes",
|
||||
"状态筛选": "Status filter",
|
||||
"状态页面Slug": "Status Page Slug",
|
||||
"环境变量": "Environment Variables",
|
||||
@@ -1949,6 +1953,19 @@
|
||||
"自动重试状态码": "Auto-retry status codes",
|
||||
"自动重试状态码格式不正确": "Invalid auto-retry status code format",
|
||||
"支持填写单个状态码或范围(含首尾),使用逗号分隔": "Supports single status codes or inclusive ranges; separate with commas",
|
||||
"支持填写单个状态码或范围(含首尾),使用逗号分隔;504 和 524 始终不重试,不受此处配置影响": "Supports single status codes or inclusive ranges; separate with commas. 504 and 524 are never retried and are not affected by this setting",
|
||||
"高危操作确认": "High-risk operation confirmation",
|
||||
"检测到以下高危状态码重定向规则": "Detected high-risk status-code redirect rules",
|
||||
"操作确认": "Operation confirmation",
|
||||
"我确认开启高危重试": "I confirm enabling high-risk retry",
|
||||
"高危状态码重试风险告知与免责声明Markdown": "### ⚠️ High-Risk Operation: Risk Notice and Disclaimer for 504/524 Retry\nBy default, this project does not retry for status codes `400` (bad request), `504` (gateway timeout), and `524` (timeout occurred).\n In many cases, 504 and 524 mean the request has reached the upstream AI service and processing has started, but the connection was closed due to long processing time.\n\nEnabling redirection/retry for these timeout status codes is a **high-risk operation**. Before enabling it, you must read and understand the consequences below:\n\n#### 1. Core Risks (Read Carefully)\n1. 💸 Duplicate/multiple billing risk: Most upstream AI providers **still charge** for requests that started processing but got interrupted by network timeout (504/524). If retry is triggered, a new upstream request will be sent, which can lead to **duplicate or multiple charges**.\n2. ⏳ Severe client timeout: If a single request already timed out, adding retries can multiply total latency and cause severe or unacceptable timeout behavior for your final client/caller.\n3. 💥 Request backlog and system crash risk: Forcing retries on timeout requests keeps threads and connections occupied for longer. Under high concurrency, this can cause serious backlog, exhaust system resources, trigger a cascading failure, and crash your proxy service.\n\n#### 2. Risk Acknowledgement\nIf you still choose to enable this feature, you acknowledge all of the following:",
|
||||
"高危状态码重试风险确认输入文本": "I understand the duplicate billing and crash risks, and confirm enabling it.",
|
||||
"高危状态码重试风险确认项1": "I have fully read and understood the risks and fully understand the destructive consequences of forcing retries for status codes 504 and 524.",
|
||||
"高危状态码重试风险确认项2": "I have communicated with the upstream provider and confirmed that the timeout issue is an upstream bottleneck and cannot be resolved upstream at this time.",
|
||||
"高危状态码重试风险确认项3": "I voluntarily accept all duplicate/multiple billing risks and will not file issues or complaints in this project repository regarding billing anomalies caused by this retry behavior.",
|
||||
"高危状态码重试风险确认项4": "I voluntarily accept system stability risks, including severe client timeout and possible service crash. Any consequences caused by enabling this feature are my own responsibility.",
|
||||
"高危状态码重试风险输入框占位文案": "Please type the exact text above",
|
||||
"高危状态码重试风险输入不匹配提示": "The input does not match the required text",
|
||||
"例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599",
|
||||
"自动选择": "Auto Select",
|
||||
"自定义充值数量选项": "Custom Recharge Amount Options",
|
||||
@@ -2818,6 +2835,7 @@
|
||||
"缓存写": "Cache Write",
|
||||
"写": "Write",
|
||||
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "Per Anthropic conventions, /v1/messages input tokens count only non-cached input and exclude cache read/write tokens.",
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50"
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "No matching models. Press Enter to add \"{{name}}\" as a custom model name."
|
||||
}
|
||||
}
|
||||
|
||||
7
web/src/i18n/locales/fr.json
vendored
7
web/src/i18n/locales/fr.json
vendored
@@ -1646,10 +1646,14 @@
|
||||
"点击查看差异": "Cliquez pour voir les différences",
|
||||
"点击此处": "cliquez ici",
|
||||
"点击预览视频": "Cliquez pour prévisualiser la vidéo",
|
||||
"点击预览音乐": "Cliquez pour écouter la musique",
|
||||
"音乐预览": "Aperçu musical",
|
||||
"音频无法播放": "Impossible de lire l'audio",
|
||||
"点击验证按钮,使用您的生物特征或安全密钥": "Cliquez sur le bouton de vérification pour utiliser vos caractéristiques biométriques ou votre clé de sécurité",
|
||||
"版权所有": "Tous droits réservés",
|
||||
"状态": "Statut",
|
||||
"状态码复写": "Remplacement du code d'état",
|
||||
"状态码复写包含无效的状态码": "Le remplacement du code d'état contient des codes d'état invalides",
|
||||
"状态筛选": "Filtre d'état",
|
||||
"状态页面Slug": "Slug de la page d'état",
|
||||
"环境变量": "Environment Variables",
|
||||
@@ -2733,6 +2737,7 @@
|
||||
"缓存写": "Écriture cache",
|
||||
"写": "Écriture",
|
||||
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "Selon la convention Anthropic, les tokens d'entrée de /v1/messages ne comptent que les entrées non mises en cache et excluent les tokens de lecture/écriture du cache.",
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50"
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "Aucun modèle correspondant. Appuyez sur Entrée pour ajouter «{{name}}» comme nom de modèle personnalisé."
|
||||
}
|
||||
}
|
||||
|
||||
7
web/src/i18n/locales/ja.json
vendored
7
web/src/i18n/locales/ja.json
vendored
@@ -1631,10 +1631,14 @@
|
||||
"点击查看差异": "差分を表示",
|
||||
"点击此处": "こちらをクリック",
|
||||
"点击预览视频": "動画をプレビュー",
|
||||
"点击预览音乐": "音楽をプレビュー",
|
||||
"音乐预览": "音楽プレビュー",
|
||||
"音频无法播放": "音声を再生できません",
|
||||
"点击验证按钮,使用您的生物特征或安全密钥": "認証ボタンをクリックし、生体情報またはセキュリティキーを使用してください",
|
||||
"版权所有": "All rights reserved",
|
||||
"状态": "ステータス",
|
||||
"状态码复写": "ステータスコードの上書き",
|
||||
"状态码复写包含无效的状态码": "ステータスコードの上書きに無効なステータスコードが含まれています",
|
||||
"状态筛选": "ステータスフィルター",
|
||||
"状态页面Slug": "ステータスページスラッグ",
|
||||
"环境变量": "Environment Variables",
|
||||
@@ -2716,6 +2720,7 @@
|
||||
"缓存写": "キャッシュ書込",
|
||||
"写": "書込",
|
||||
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "Anthropic の仕様により、/v1/messages の入力 tokens は非キャッシュ入力のみを集計し、キャッシュ読み取り/書き込み tokens は含みません。",
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50"
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "一致するモデルが見つかりません。Enterキーで「{{name}}」をカスタムモデル名として追加できます。"
|
||||
}
|
||||
}
|
||||
|
||||
7
web/src/i18n/locales/ru.json
vendored
7
web/src/i18n/locales/ru.json
vendored
@@ -1657,10 +1657,14 @@
|
||||
"点击查看差异": "Нажмите для просмотра различий",
|
||||
"点击此处": "Нажмите здесь",
|
||||
"点击预览视频": "Нажмите для предварительного просмотра видео",
|
||||
"点击预览音乐": "Нажмите для прослушивания музыки",
|
||||
"音乐预览": "Предварительное прослушивание",
|
||||
"音频无法播放": "Не удалось воспроизвести аудио",
|
||||
"点击验证按钮,使用您的生物特征或安全密钥": "Нажмите кнопку проверки, используйте ваши биометрические данные или ключ безопасности",
|
||||
"版权所有": "Все права защищены",
|
||||
"状态": "Статус",
|
||||
"状态码复写": "Перезапись кода состояния",
|
||||
"状态码复写包含无效的状态码": "Перезапись кода состояния содержит недопустимые коды состояния",
|
||||
"状态筛选": "Фильтр по статусу",
|
||||
"状态页面Slug": "Slug страницы статуса",
|
||||
"环境变量": "Environment Variables",
|
||||
@@ -2746,6 +2750,7 @@
|
||||
"缓存写": "Запись в кэш",
|
||||
"写": "Запись",
|
||||
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "Согласно соглашению Anthropic, входные токены /v1/messages учитывают только некэшированный ввод и не включают токены чтения/записи кэша.",
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50"
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "Совпадающих моделей не найдено. Нажмите Enter, чтобы добавить «{{name}}» как пользовательское имя модели."
|
||||
}
|
||||
}
|
||||
|
||||
7
web/src/i18n/locales/vi.json
vendored
7
web/src/i18n/locales/vi.json
vendored
@@ -1773,6 +1773,9 @@
|
||||
"点击链接重置密码": "Nhấp vào liên kết để đặt lại mật khẩu",
|
||||
"点击阅读": "Nhấp để đọc",
|
||||
"点击预览视频": "Nhấp để xem trước video",
|
||||
"点击预览音乐": "Nhấp để nghe nhạc",
|
||||
"音乐预览": "Xem trước nhạc",
|
||||
"音频无法播放": "Không thể phát âm thanh",
|
||||
"点击验证按钮,使用您的生物特征或安全密钥": "Nhấp vào nút xác minh và sử dụng sinh trắc học hoặc khóa bảo mật của bạn",
|
||||
"版": "Phiên bản",
|
||||
"版本": "Phiên bản",
|
||||
@@ -1782,6 +1785,7 @@
|
||||
"状态": "Trạng thái",
|
||||
"状态更新时间": "Thời gian cập nhật trạng thái",
|
||||
"状态码复写": "Ghi đè mã trạng thái",
|
||||
"状态码复写包含无效的状态码": "Ghi đè mã trạng thái chứa mã trạng thái không hợp lệ",
|
||||
"状态筛选": "Lọc trạng thái",
|
||||
"状态页面Slug": "Slug trang trạng thái",
|
||||
"环境变量": "Environment Variables",
|
||||
@@ -3292,6 +3296,7 @@
|
||||
"缓存写": "Ghi bộ nhớ đệm",
|
||||
"写": "Ghi",
|
||||
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "Theo quy ước của Anthropic, input tokens của /v1/messages chỉ tính phần đầu vào không dùng cache và không bao gồm tokens đọc/ghi cache.",
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50"
|
||||
"设计版本": "b80c3466cb6feafeb3990c7820e10e50",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "Không tìm thấy mô hình khớp. Nhấn Enter để thêm \"{{name}}\" làm tên mô hình tùy chỉnh."
|
||||
}
|
||||
}
|
||||
|
||||
20
web/src/i18n/locales/zh-CN.json
vendored
20
web/src/i18n/locales/zh-CN.json
vendored
@@ -1624,10 +1624,14 @@
|
||||
"点击查看差异": "点击查看差异",
|
||||
"点击此处": "点击此处",
|
||||
"点击预览视频": "点击预览视频",
|
||||
"点击预览音乐": "点击预览音乐",
|
||||
"音乐预览": "音乐预览",
|
||||
"音频无法播放": "音频无法播放",
|
||||
"点击验证按钮,使用您的生物特征或安全密钥": "点击验证按钮,使用您的生物特征或安全密钥",
|
||||
"版权所有": "版权所有",
|
||||
"状态": "状态",
|
||||
"状态码复写": "状态码复写",
|
||||
"状态码复写包含无效的状态码": "状态码复写包含无效的状态码",
|
||||
"状态筛选": "状态筛选",
|
||||
"状态页面Slug": "状态页面Slug",
|
||||
"环境变量": "环境变量",
|
||||
@@ -1936,6 +1940,19 @@
|
||||
"自动重试状态码": "自动重试状态码",
|
||||
"自动重试状态码格式不正确": "自动重试状态码格式不正确",
|
||||
"支持填写单个状态码或范围(含首尾),使用逗号分隔": "支持填写单个状态码或范围(含首尾),使用逗号分隔",
|
||||
"支持填写单个状态码或范围(含首尾),使用逗号分隔;504 和 524 始终不重试,不受此处配置影响": "支持填写单个状态码或范围(含首尾),使用逗号分隔;504 和 524 始终不重试,不受此处配置影响",
|
||||
"高危操作确认": "高危操作确认",
|
||||
"检测到以下高危状态码重定向规则": "检测到以下高危状态码重定向规则",
|
||||
"操作确认": "操作确认",
|
||||
"我确认开启高危重试": "我确认开启高危重试",
|
||||
"高危状态码重试风险告知与免责声明Markdown": "### ⚠️ 高危操作:504/524 状态码重试风险告知与免责声明\n本项目默认对 `400 (请求错误)`、`504 (网关超时)`和 `524 (cdn发生超时)`状态码不进行重试。\n504 和 524 错误通常意味着**请求已成功送达上游 AI 服务,且上游正在处理,但因处理时间过长导致连接断开**。\n\n开启对此类超时状态码的重定向/重试属于**极高风险操作**。作为本开源项目的使用者,在开启该功能前,您必须仔细阅读并知悉以下严重后果:\n\n#### 一、 核心风险告知(请仔细阅读)\n1. 💸 双重/多重计费风险: 绝大多数 AI 上游厂商对于已经开始处理但因网络原因中断(504/524)的请求**依然会进行扣费**。此时若触发重试,将会向上游发起全新请求,导致您被**双重甚至多重计费**。\n2. ⏳ 客户端严重超时: 单次请求已经触发超时,叠加重试机制将会使总请求耗时成倍增加,导致您的最终客户端(或调用方)出现严重甚至完全无法接受的超时现象。\n3. 💥 请求积压与系统崩溃风险: 强制重试超时请求会长时间占用系统线程和连接数。在高并发场景下,这会导致严重的**请求积压**,进而耗尽系统资源,引发雪崩效应,导致您的整个代理服务崩溃。\n\n#### 二、 风险确认声明\n如果您坚持开启该功能,即代表您作出以下确认:",
|
||||
"高危状态码重试风险确认输入文本": "我已了解多重计费与崩溃风险,确认开启",
|
||||
"高危状态码重试风险确认项1": "我已充分阅读并理解:本人已完整阅读上述全部风险提示,完全理解强制重试 504 和 524 状态码可能带来的破坏性后果。",
|
||||
"高危状态码重试风险确认项2": "我已与上游沟通并确认:本人确认,当前出现的超时问题属于上游服务的瓶颈。本人已与上游提供商进行过沟通,确认上游无法解决该超时问题,因此才采取强制重试方案作为妥协手段。",
|
||||
"高危状态码重试风险确认项3": "我自愿承担计费损失:本人知晓并接受由此产生的全部双重/多重计费风险,承诺不会因重试导致的账单异常在本项目仓库中提交 Issue 或抱怨。",
|
||||
"高危状态码重试风险确认项4": "我自愿承担系统稳定性风险:本人知晓该操作可能导致客户端严重超时及服务崩溃。若因本人开启此功能导致请求积压或服务不可用,后果由本人自行承担。",
|
||||
"高危状态码重试风险输入框占位文案": "请完整输入上方文字",
|
||||
"高危状态码重试风险输入不匹配提示": "输入内容与要求不一致",
|
||||
"例如:401, 403, 429, 500-599": "例如:401,403,429,500-599",
|
||||
"自动选择": "自动选择",
|
||||
"自定义充值数量选项": "自定义充值数量选项",
|
||||
@@ -2795,6 +2812,7 @@
|
||||
"缓存读": "缓存读",
|
||||
"缓存写": "缓存写",
|
||||
"写": "写",
|
||||
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。"
|
||||
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加"
|
||||
}
|
||||
}
|
||||
|
||||
20
web/src/i18n/locales/zh-TW.json
vendored
20
web/src/i18n/locales/zh-TW.json
vendored
@@ -1628,10 +1628,14 @@
|
||||
"点击查看差异": "點擊查看差異",
|
||||
"点击此处": "點擊此處",
|
||||
"点击预览视频": "點擊預覽影片",
|
||||
"点击预览音乐": "點擊預覽音樂",
|
||||
"音乐预览": "音樂預覽",
|
||||
"音频无法播放": "音訊無法播放",
|
||||
"点击验证按钮,使用您的生物特征或安全密钥": "點擊驗證按鈕,使用您的生物特徵或安全密鑰",
|
||||
"版权所有": "版權所有",
|
||||
"状态": "狀態",
|
||||
"状态码复写": "狀態碼複寫",
|
||||
"状态码复写包含无效的状态码": "狀態碼複寫包含無效的狀態碼",
|
||||
"状态筛选": "狀態篩選",
|
||||
"状态页面Slug": "狀態頁面Slug",
|
||||
"环境变量": "環境變數",
|
||||
@@ -1942,6 +1946,19 @@
|
||||
"自动重试状态码": "自動重試狀態碼",
|
||||
"自动重试状态码格式不正确": "自動重試狀態碼格式不正確",
|
||||
"支持填写单个状态码或范围(含首尾),使用逗号分隔": "支援填寫單個狀態碼或範圍(含首尾),使用逗號分隔",
|
||||
"支持填写单个状态码或范围(含首尾),使用逗号分隔;504 和 524 始终不重试,不受此处配置影响": "支援填寫單個狀態碼或範圍(含首尾),使用逗號分隔;504 和 524 一律不重試,不受此處設定影響",
|
||||
"高危操作确认": "高風險操作確認",
|
||||
"检测到以下高危状态码重定向规则": "檢測到以下高風險狀態碼重定向規則",
|
||||
"操作确认": "操作確認",
|
||||
"我确认开启高危重试": "我確認開啟高風險重試",
|
||||
"高危状态码重试风险告知与免责声明Markdown": "### ⚠️ 高風險操作:504/524 狀態碼重試風險告知與免責聲明\n\n【背景提示】\n本專案預設對 `400`(請求錯誤)、`504`(閘道逾時)與 `524`(發生逾時)狀態碼不進行重試。504 與 524 錯誤通常代表**請求已成功送達上游 AI 服務,且上游正在處理,但因處理時間過長導致連線中斷**。\n\n開啟此類逾時狀態碼的重定向/重試屬於**極高風險操作**。作為本開源專案使用者,在開啟該功能前,您必須仔細閱讀並知悉以下嚴重後果:\n\n#### 一、 核心風險告知(請仔細閱讀)\n1. 💸 雙重/多重計費風險:多數 AI 上游廠商對於已開始處理但因網路原因中斷(504/524)的請求**仍然會扣費**。此時若觸發重試,將會向上游發起全新請求,導致您被**雙重甚至多重計費**。\n2. ⏳ 用戶端嚴重逾時:單次請求已觸發逾時,疊加重試機制會使總請求耗時成倍增加,導致最終用戶端(或呼叫方)出現嚴重甚至無法接受的逾時現象。\n3. 💥 請求積壓與系統崩潰風險:強制重試逾時請求會長時間占用系統執行緒與連線數。在高併發場景下,這將導致嚴重**請求積壓**,進而耗盡系統資源,引發雪崩效應,造成整個代理服務崩潰。\n\n#### 二、 風險確認聲明\n若您堅持開啟該功能,即代表您作出以下確認:",
|
||||
"高危状态码重试风险确认输入文本": "我已了解多重計費與崩潰風險,確認開啟",
|
||||
"高危状态码重试风险确认项1": "我已充分閱讀並理解:本人已完整閱讀上述全部風險提示,完全理解強制重試 504 與 524 狀態碼可能帶來的破壞性後果。",
|
||||
"高危状态码重试风险确认项2": "我已與上游溝通並確認:本人確認,當前逾時問題屬於上游服務瓶頸。本人已與上游供應商溝通,確認上游無法解決該逾時問題,因此才採取強制重試方案作為妥協手段。",
|
||||
"高危状态码重试风险确认项3": "我自願承擔計費損失:本人知悉並接受由此產生的全部雙重/多重計費風險,承諾不會因重試導致的帳單異常在本專案倉庫提交 Issue 或抱怨。",
|
||||
"高危状态码重试风险确认项4": "我自願承擔系統穩定性風險:本人知悉該操作可能導致用戶端嚴重逾時及服務崩潰。若因本人開啟此功能導致請求積壓或服務不可用,後果由本人自行承擔。",
|
||||
"高危状态码重试风险输入框占位文案": "請完整輸入上方文字",
|
||||
"高危状态码重试风险输入不匹配提示": "輸入內容與要求不一致",
|
||||
"例如:401, 403, 429, 500-599": "例如:401,403,429,500-599",
|
||||
"自动选择": "自動選擇",
|
||||
"自定义充值数量选项": "自訂儲值數量選項",
|
||||
@@ -2788,6 +2805,7 @@
|
||||
"填写服务器地址后自动生成:": "填寫伺服器位址後自動生成:",
|
||||
"自动生成:": "自動生成:",
|
||||
"请先填写服务器地址,以自动生成完整的端点 URL": "請先填寫伺服器位址,以自動生成完整的端點 URL",
|
||||
"端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端點 URL 必須是完整位址(以 http:// 或 https:// 開頭)"
|
||||
"端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端點 URL 必須是完整位址(以 http:// 或 https:// 開頭)",
|
||||
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按下 Enter 鍵可將「{{name}}」作為自訂模型名稱新增"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ export default function SettingsMonitoring(props) {
|
||||
label={t('自动重试状态码')}
|
||||
placeholder={t('例如:401, 403, 429, 500-599')}
|
||||
extraText={t(
|
||||
'支持填写单个状态码或范围(含首尾),使用逗号分隔',
|
||||
'支持填写单个状态码或范围(含首尾),使用逗号分隔;504 和 524 始终不重试,不受此处配置影响',
|
||||
)}
|
||||
field={'AutomaticRetryStatusCodes'}
|
||||
onChange={(value) =>
|
||||
|
||||
Reference in New Issue
Block a user