Merge tag 'v0.12.12' into codex/redeem-subscription

v0.12.12

# -----BEGIN PGP SIGNATURE-----
#
# iQIzBAABCAAdFiEELDLYXndFTEpGR0CuDPphNSmpkh0FAmnhzLIACgkQDPphNSmp
# kh19mg/+JdGRYXBMPXap8sJCWZ6QNkro5/PtNyy8GOiqDPb42Ud2QM7472P8IZj1
# TIVa/cjU4Vg+BJYKkvJ5oHmJBFLsvLUyjyhKEU7hdrgBBufNGriJsTERYNAwAzHo
# pnzbzVzQClN19ficvbobdkiT4zBpxmyC4kuWRcDePQga10wX2oTyAlBsZ2MZg2Go
# 2ceXPgkfatEMkwjULZtaVC3BxznQ5nhEpSPOSeJufKsf/tHpso3IfcoA7be62Mmd
# gh0syQUV4dH2PJ1kz/ihentLse4SDI1018fn6W2fBxAwcamaFP/Arr366c1pLck0
# IeISGUfoScSHGTu2dHJ3h0nuOqATyd/mXfzNnox58p+eQ+52eSfyiOyjx5sQ04WG
# SRgso3Zl6F/fBcCKy8YPHAIJVuoRJMSfi0QSdMD3pdhdwb6E5GQ+J2y80WDg7XN3
# PPrRRp5Quq4aSSMBVcuCvANIFHu6DeyLm8MIBbeujByXzW6ODDpEo74HxCeUKqE+
# Dbwo7c5GMY4mUdQTGo4QuFVB+OFcPY+d4uSRwt0YDT4s9ySJNyNJg2fv6JOwMQVx
# wIfpI5pOrgGDpQgTjeMw1NzA6oPHaOk41ZcqzJ1onB+hh0KYO+401cXFoVkZqxW/
# uHZTspy56M71HsCqNmB6MtqScNWlQhKuRxa4drMRqT+QddbiWHo=
# =Byz/
# -----END PGP SIGNATURE-----

# Conflicts:
#	model/redemption.go
#	web/package.json
#	web/src/components/table/redemptions/modals/EditRedemptionModal.jsx
This commit is contained in:
Lich-Mac-Mini
2026-04-17 19:29:19 +08:00
127 changed files with 7555 additions and 3307 deletions
+2
View File
@@ -19,6 +19,8 @@
# HOSTNAME=your-hostname
# 数据库相关配置
# 启用错误日志记录
# ERROR_LOG_ENABLED=true
# 数据库连接字符串
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
# 日志数据库连接字符串
+28
View File
@@ -0,0 +1,28 @@
# ⚠️ 提交说明 / PR Notice
> [!IMPORTANT]
>
> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
@@ -1,29 +0,0 @@
# ⚠️ 提交警告 / PR Warning
> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。
---
## 💡 沟通提示 / Pre-submission
> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话)
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix)
- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。
- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
+33
View File
@@ -0,0 +1,33 @@
name: PR Check
permissions:
contents: read
issues: read
pull-requests: read
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0.2.1
with:
max-failures: 4
require-description: true
# require-linked-issue: false
blocked-terms: |
🤖 Generated with Claude Code
require-pr-template: true
strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
detect-spam-usernames: true
min-account-age: 30
failure-add-pr-labels: "pr-check-failed"
failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
close-pr: true
+2
View File
@@ -29,3 +29,5 @@ data/
.gomodcache/
.gocache-temp
.gopath
token_estimator_test.go
+1
View File
@@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
var SMTPServer = ""
var SMTPPort = 587
var SMTPSSLEnabled = false
var SMTPForceAuthLogin = false
var SMTPAccount = ""
var SMTPFrom = ""
var SMTPToken = ""
+15 -4
View File
@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
}
func shouldUseSMTPLoginAuth() bool {
if SMTPForceAuthLogin {
return true
}
return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
}
func getSMTPAuth() smtp.Auth {
if shouldUseSMTPLoginAuth() {
return LoginAuth(SMTPAccount, SMTPToken)
}
return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
}
func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
auth := getSMTPAuth()
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";")
var err error
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
if err != nil {
return err
}
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
auth = LoginAuth(SMTPAccount, SMTPToken)
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
+1
View File
@@ -65,4 +65,5 @@ const (
// ContextKeyLanguage stores the user's language preference for i18n
ContextKeyLanguage ContextKey = "language"
ContextKeyIsStream ContextKey = "is_stream"
)
+10 -4
View File
@@ -150,6 +150,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
}
}
cache.WriteContext(c)
c.Set("id", 1)
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
c.Request.Header.Set("Content-Type", "application/json")
@@ -274,7 +275,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)),
}
}
@@ -756,11 +757,15 @@ func TestChannel(c *gin.Context) {
tik := time.Now()
result := testChannel(channel, testModel, endpointType, isStream)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
resp := gin.H{
"success": false,
"message": result.localErr.Error(),
"time": 0.0,
})
}
if result.newAPIError != nil {
resp["error_code"] = result.newAPIError.GetErrorCode()
}
c.JSON(http.StatusOK, resp)
return
}
tok := time.Now()
@@ -772,6 +777,7 @@ func TestChannel(c *gin.Context) {
"success": false,
"message": result.newAPIError.Error(),
"time": consumedTime,
"error_code": result.newAPIError.GetErrorCode(),
})
return
}
@@ -824,7 +830,7 @@ func testAllChannels(notify bool) error {
newAPIError := result.newAPIError
// request error disables the channel
if newAPIError != nil {
shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError)
shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
}
// 当错误检查通过,才检查响应时间
+26
View File
@@ -1,6 +1,7 @@
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -8,6 +9,30 @@ import (
"github.com/gin-gonic/gin"
)
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
if len(pricing) == 0 {
return pricing
}
if len(usableGroup) == 0 {
return []model.Pricing{}
}
filtered := make([]model.Pricing, 0, len(pricing))
for _, item := range pricing {
if common.StringsContains(item.EnableGroup, "all") {
filtered = append(filtered, item)
continue
}
for _, group := range item.EnableGroup {
if _, ok := usableGroup[group]; ok {
filtered = append(filtered, item)
break
}
}
}
return filtered
}
func GetPricing(c *gin.Context) {
pricing := model.GetPricing()
userId, exists := c.Get("id")
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
}
usableGroup = service.GetUserUsableGroups(group)
pricing = filterPricingByUsableGroups(pricing, usableGroup)
// check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok {
+4 -4
View File
@@ -151,7 +151,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest))
return
}
@@ -351,7 +351,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(channelError.ChannelType, err) && channelError.AutoBan {
if service.ShouldDisableChannel(err) && channelError.AutoBan {
gopool.Go(func() {
service.DisableChannel(channelError, err.ErrorWithStatusCode())
})
@@ -389,7 +389,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, false, userGroup, other)
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
}
}
@@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) {
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios,
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName),
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
}
task.Quota = result.Quota
task.Data = result.TaskData
+23
View File
@@ -334,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
"data": count,
})
}
func GetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if len(tokenBatch.Ids) > 100 {
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
return
}
userId := c.GetInt("id")
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
if err != nil {
common.ApiError(c, err)
return
}
keysMap := make(map[int]string)
for _, t := range tokens {
keysMap[t.Id] = t.GetFullKey()
}
common.ApiSuccess(c, gin.H{"keys": keysMap})
}
+4
View File
@@ -340,6 +340,10 @@ func EpayNotify(c *gin.Context) {
log.Printf("易支付回调未找到订单: %v", verifyInfo)
return
}
if topUp.PaymentMethod == "stripe" || topUp.PaymentMethod == "creem" || topUp.PaymentMethod == "waffo" {
log.Printf("易支付回调订单支付方式不匹配: %s, 订单号: %s", topUp.PaymentMethod, verifyInfo.ServiceTradeNo)
return
}
if topUp.Status == "pending" {
topUp.Status = "success"
err := topUp.Update()
+1
View File
@@ -112,6 +112,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
Amount: selectedProduct.Quota, // 充值额度
Money: selectedProduct.Price, // 支付金额
TradeNo: referenceId,
PaymentMethod: PaymentMethodCreem,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
+75 -3
View File
@@ -146,6 +146,12 @@ func RequestStripePay(c *gin.Context) {
}
func StripeWebhook(c *gin.Context) {
if setting.StripeWebhookSecret == "" {
log.Println("Stripe Webhook Secret 未配置,拒绝处理")
c.AbortWithStatus(http.StatusForbidden)
return
}
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("解析Stripe Webhook参数失败: %v\n", err)
@@ -154,8 +160,7 @@ func StripeWebhook(c *gin.Context) {
}
signature := c.GetHeader("Stripe-Signature")
endpointSecret := setting.StripeWebhookSecret
event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{
event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
@@ -170,6 +175,10 @@ func StripeWebhook(c *gin.Context) {
sessionCompleted(event)
case stripe.EventTypeCheckoutSessionExpired:
sessionExpired(event)
case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
sessionAsyncPaymentSucceeded(event)
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
sessionAsyncPaymentFailed(event)
default:
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
}
@@ -186,7 +195,70 @@ func sessionCompleted(event stripe.Event) {
return
}
// Try complete subscription order first
paymentStatus := event.GetObjectValue("payment_status")
if paymentStatus != "paid" {
log.Printf("Stripe Checkout 支付尚未完成,payment_status: %s, ref: %s(等待异步支付结果)", paymentStatus, referenceId)
return
}
fulfillOrder(event, referenceId, customerId)
}
// sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
// that confirm payment after the checkout session completes.
func sessionAsyncPaymentSucceeded(event stripe.Event) {
customerId := event.GetObjectValue("customer")
referenceId := event.GetObjectValue("client_reference_id")
log.Printf("Stripe 异步支付成功: %s", referenceId)
fulfillOrder(event, referenceId, customerId)
}
// sessionAsyncPaymentFailed marks orders as failed when delayed payment methods
// ultimately fail (e.g. bank transfer not received, SEPA rejected).
func sessionAsyncPaymentFailed(event stripe.Event) {
referenceId := event.GetObjectValue("client_reference_id")
log.Printf("Stripe 异步支付失败: %s", referenceId)
if len(referenceId) == 0 {
log.Println("异步支付失败事件未提供支付单号")
return
}
LockOrder(referenceId)
defer UnlockOrder(referenceId)
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Println("异步支付失败,充值订单不存在:", referenceId)
return
}
if topUp.PaymentMethod != PaymentMethodStripe {
log.Printf("异步支付失败,订单支付方式不匹配: %s, ref: %s", topUp.PaymentMethod, referenceId)
return
}
if topUp.Status != common.TopUpStatusPending {
log.Printf("异步支付失败,订单状态非pending: %s, ref: %s", topUp.Status, referenceId)
return
}
topUp.Status = common.TopUpStatusFailed
if err := topUp.Update(); err != nil {
log.Printf("标记充值订单失败出错: %v, ref: %s", err, referenceId)
return
}
log.Printf("充值订单已标记为失败: %s", referenceId)
}
// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
func fulfillOrder(event stripe.Event, referenceId string, customerId string) {
if len(referenceId) == 0 {
log.Println("未提供支付单号")
return
}
LockOrder(referenceId)
defer UnlockOrder(referenceId)
payload := map[string]any{
+15
View File
@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
return
}
func GetQuotaDatesByUser(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": dates,
})
}
func GetUserQuotaDates(c *gin.Context) {
userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+53 -7
View File
@@ -52,10 +52,15 @@ func Login(c *gin.Context) {
}
err = user.ValidateAndFill()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
switch {
case errors.Is(err, model.ErrDatabase):
common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err))
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
case errors.Is(err, model.ErrUserEmptyCredentials):
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
default:
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
}
return
}
@@ -572,9 +577,6 @@ func UpdateUser(c *gin.Context) {
common.ApiError(c, err)
return
}
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -841,6 +843,8 @@ func CreateUser(c *gin.Context) {
type ManageRequest struct {
Id int `json:"id"`
Action string `json:"action"`
Value int `json:"value"`
Mode string `json:"mode"`
}
// ManageUser Only admin user can do this
@@ -907,6 +911,48 @@ func ManageUser(c *gin.Context) {
return
}
user.Role = common.RoleCommonUser
case "add_quota":
adminName := c.GetString("username")
switch req.Mode {
case "add":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员(%s)增加用户额度 %s", adminName, logger.LogQuota(req.Value)))
case "subtract":
if req.Value <= 0 {
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
return
}
if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员(%s)减少用户额度 %s", adminName, logger.LogQuota(req.Value)))
case "override":
oldQuota := user.Quota
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(user.Id, model.LogTypeManage,
fmt.Sprintf("管理员(%s)覆盖用户额度从 %s 为 %s", adminName, logger.LogQuota(oldQuota), logger.LogQuota(req.Value)))
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
if err := user.Update(false); err != nil {
+53 -1
View File
@@ -3281,6 +3281,13 @@
}
]
},
"cache_control": {
"type": "object",
"properties": {}
},
"inference_geo": {
"type": "string"
},
"max_tokens": {
"type": "integer",
"minimum": 1
@@ -3333,7 +3340,8 @@
"enum": [
"auto",
"any",
"tool"
"tool",
"none"
]
},
"name": {
@@ -3358,6 +3366,36 @@
}
}
},
"context_management": {
"type": "object",
"properties": {}
},
"output_config": {
"type": "object",
"properties": {}
},
"output_format": {
"type": "object",
"properties": {}
},
"container": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {}
}
]
},
"mcp_servers": {
"type": "array",
"items": {
"type": "object",
"properties": {}
}
},
"metadata": {
"type": "object",
"properties": {
@@ -3365,6 +3403,20 @@
"type": "string"
}
}
},
"speed": {
"type": "string",
"enum": [
"standard",
"fast"
]
},
"service_tier": {
"type": "string",
"enum": [
"auto",
"standard_only"
]
}
}
},
+10
View File
@@ -18,6 +18,16 @@ type AudioRequest struct {
Speed *float64 `json:"speed,omitempty"`
StreamFormat string `json:"stream_format,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// vllm-omini
TaskType json.RawMessage `json:"task_type,omitempty"`
Language json.RawMessage `json:"language,omitempty"`
RefAudio json.RawMessage `json:"ref_audio,omitempty"`
RefText json.RawMessage `json:"ref_text,omitempty"`
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
// TODOensure that the logic remains correct after the stream is started.
//Stream json.RawMessage `json:"stream,omitempty"`
}
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
+1
View File
@@ -30,6 +30,7 @@ type ChannelOtherSettings struct {
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,默认过滤以满足数据驻留合规
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
+27 -24
View File
@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
return mediaContent
}
func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
if m.Source == nil {
return nil
}
data := m.Source.Url
if data == "" {
data = common.Interface2String(m.Source.Data)
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, m.Source.MediaType)
}
type ClaudeMessageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
@@ -194,6 +208,7 @@ type ClaudeRequest struct {
Prompt string `json:"prompt,omitempty"`
System any `json:"system,omitempty"`
Messages []ClaudeMessage `json:"messages,omitempty"`
CacheControl json.RawMessage `json:"cache_control,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"`
@@ -213,6 +228,9 @@ type ClaudeRequest struct {
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// Speed specifies the Claude inference speed mode.
// This field is filtered by default and can be enabled via channel setting allow_speed.
Speed json.RawMessage `json:"speed,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"`
@@ -223,14 +241,6 @@ type OutputConfigForEffort struct {
Effort string `json:"effort,omitempty"`
}
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
func createClaudeFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
maxTokens := 0
if c.MaxTokens != nil {
@@ -258,22 +268,16 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text":
texts = append(texts, media.GetText())
case "image":
if media.Source != nil {
data := media.Source.Url
if data == "" {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
Source: source,
})
}
}
}
}
}
}
// messages
for _, message := range c.Messages {
@@ -293,18 +297,12 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text":
texts = append(texts, media.GetText())
case "image":
if media.Source != nil {
data := media.Source.Url
if data == "" {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
Source: source,
})
}
}
case "tool_use":
if media.Name != "" {
texts = append(texts, media.Name)
@@ -450,6 +448,11 @@ func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
type Thinking struct {
Type string `json:"type,omitempty"`
BudgetTokens *int `json:"budget_tokens,omitempty"`
// Display controls whether thinking content is returned in the response.
// Used with adaptive thinking on Claude Opus 4.7+: "summarized" restores
// the visible summary that was default on Opus 4.6; "omitted" (default on
// 4.7) suppresses it. Pass-through field from upstream Anthropic API.
Display string `json:"display,omitempty"`
}
func (c *Thinking) GetBudgetTokens() int {
+8 -11
View File
@@ -64,14 +64,6 @@ type LatLng struct {
Longitude *float64 `json:"longitude,omitempty"`
}
// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
func createGeminiFileSource(data string, mimeType string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, mimeType)
}
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
var files []*types.FileMeta = make([]*types.FileMeta, 0)
@@ -87,9 +79,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
if part.InlineData != nil && part.InlineData.Data != "" {
if source := part.InlineData.ToFileSource(); source != nil {
mimeType := part.InlineData.MimeType
source := createGeminiFileSource(part.InlineData.Data, mimeType)
var fileType types.FileType
if strings.HasPrefix(mimeType, "image/") {
fileType = types.FileTypeImage
@@ -103,7 +94,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
files = append(files, &types.FileMeta{
FileType: fileType,
Source: source,
MimeType: mimeType,
})
}
}
@@ -215,6 +205,13 @@ type GeminiInlineData struct {
Data string `json:"data"`
}
func (d *GeminiInlineData) ToFileSource() types.FileSource {
if d == nil || d.Data == "" {
return nil
}
return types.NewFileSourceFromData(d.Data, d.MimeType)
}
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
type Alias GeminiInlineData // Use type alias to avoid recursion
+52 -46
View File
@@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
}
// createFileSource 根据数据内容创建正确类型的 FileSource
func createFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta types.TokenCountMeta
var texts = make([]string, 0)
@@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
arrayContent := message.ParseContent()
for _, m := range arrayContent {
if m.Type == ContentTypeImageURL {
imageUrl := m.GetImageMedia()
if imageUrl != nil && imageUrl.Url != "" {
source := createFileSource(imageUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
Detail: imageUrl.Detail,
})
source := m.ToFileSource()
if source != nil {
meta := &types.FileMeta{Source: source}
switch m.Type {
case ContentTypeImageURL:
meta.FileType = types.FileTypeImage
if img := m.GetImageMedia(); img != nil {
meta.Detail = img.Detail
}
} else if m.Type == ContentTypeInputAudio {
inputAudio := m.GetInputAudio()
if inputAudio != nil && inputAudio.Data != "" {
source := createFileSource(inputAudio.Data)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeAudio,
Source: source,
})
case ContentTypeInputAudio:
meta.FileType = types.FileTypeAudio
case ContentTypeFile:
meta.FileType = types.FileTypeFile
case ContentTypeVideoUrl:
meta.FileType = types.FileTypeVideo
}
} else if m.Type == ContentTypeFile {
file := m.GetFile()
if file != nil && file.FileData != "" {
source := createFileSource(file.FileData)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
Source: source,
})
}
} else if m.Type == ContentTypeVideoUrl {
videoUrl := m.GetVideoUrl()
if videoUrl != nil && videoUrl.Url != "" {
source := createFileSource(videoUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeVideo,
Source: source,
})
}
} else {
fileMeta = append(fileMeta, meta)
} else if m.Type == ContentTypeText {
texts = append(texts, m.Text)
}
}
@@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
return nil
}
func (m *MediaContent) ToFileSource() types.FileSource {
switch m.Type {
case ContentTypeImageURL:
img := m.GetImageMedia()
if img == nil || img.Url == "" {
return nil
}
return types.NewFileSourceFromData(img.Url, img.MimeType)
case ContentTypeInputAudio:
audio := m.GetInputAudio()
if audio == nil || audio.Data == "" {
return nil
}
mimeType := ""
if audio.Format != "" {
mimeType = "audio/" + audio.Format
}
return types.NewFileSourceFromData(audio.Data, mimeType)
case ContentTypeFile:
file := m.GetFile()
if file == nil || file.FileData == "" {
return nil
}
return types.NewFileSourceFromData(file.FileData, "")
case ContentTypeVideoUrl:
video := m.GetVideoUrl()
if video == nil || video.Url == "" {
return nil
}
return types.NewFileSourceFromData(video.Url, "")
}
return nil
}
type MessageImageUrl struct {
Url string `json:"url"`
Detail string `json:"detail,omitempty"`
@@ -865,7 +871,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.ImageUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createFileSource(input.ImageUrl),
Source: types.NewFileSourceFromData(input.ImageUrl, ""),
Detail: input.Detail,
})
}
@@ -873,7 +879,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.FileUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
Source: createFileSource(input.FileUrl),
Source: types.NewFileSourceFromData(input.FileUrl, ""),
})
}
} else {
+1 -1
View File
@@ -272,7 +272,7 @@ type OpenAIResponsesResponse struct {
Status json.RawMessage `json:"status"`
Error any `json:"error,omitempty"`
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
Instructions string `json:"instructions"`
Instructions json.RawMessage `json:"instructions"`
MaxOutputTokens int `json:"max_output_tokens"`
Model string `json:"model"`
Output []ResponsesOutput `json:"output"`
+6 -6
View File
@@ -8,9 +8,9 @@ require (
github.com/abema/go-mp4 v1.4.1
github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.41.2
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/aws/smithy-go v1.24.2
github.com/bytedance/gopkg v0.1.3
github.com/gin-contrib/cors v1.7.2
@@ -63,9 +63,9 @@ require (
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
@@ -96,7 +96,7 @@ require (
github.com/icza/bitio v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/pgx/v5 v5.9.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
+12 -12
View File
@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -152,8 +152,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
+14
View File
@@ -25,6 +25,19 @@ const (
MsgDeleteFailed = "common.delete_failed"
MsgAlreadyExists = "common.already_exists"
MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
MsgBatchTooMany = "common.batch_too_many"
)
// Auth middleware messages
const (
MsgAuthNotLoggedIn = "auth.not_logged_in"
MsgAuthAccessTokenInvalid = "auth.access_token_invalid"
MsgAuthUserInfoInvalid = "auth.user_info_invalid"
MsgAuthUserIdNotProvided = "auth.user_id_not_provided"
MsgAuthUserIdFormatError = "auth.user_id_format_error"
MsgAuthUserIdMismatch = "auth.user_id_mismatch"
MsgAuthUserBanned = "auth.user_banned"
MsgAuthInsufficientPrivilege = "auth.insufficient_privilege"
)
// Token related messages
@@ -100,6 +113,7 @@ const (
MsgUserTelegramIdEmpty = "user.telegram_id_empty"
MsgUserTelegramNotBound = "user.telegram_not_bound"
MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty"
MsgUserQuotaChangeZero = "user.quota_change_zero"
)
// Quota related messages
+13 -1
View File
@@ -2,7 +2,7 @@
# Common messages
common.invalid_params: "Invalid parameters"
common.database_error: "Database error, please try again later"
common.database_error: "Database error, please contact the administrator"
common.retry_later: "Please try again later"
common.generate_failed: "Generation failed"
common.not_found: "Not found"
@@ -21,6 +21,17 @@ common.delete_success: "Deletion successful"
common.delete_failed: "Deletion failed"
common.already_exists: "Already exists"
common.name_cannot_be_empty: "Name cannot be empty"
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
# Auth middleware messages
auth.not_logged_in: "Unauthorized, not logged in and no access token provided"
auth.access_token_invalid: "Unauthorized, invalid access token"
auth.user_info_invalid: "Unauthorized, invalid user info"
auth.user_id_not_provided: "Unauthorized, New-Api-User header not provided"
auth.user_id_format_error: "Unauthorized, New-Api-User header format error"
auth.user_id_mismatch: "Unauthorized, New-Api-User does not match logged in user"
auth.user_banned: "User has been banned"
auth.insufficient_privilege: "Unauthorized, insufficient privileges"
# Token messages
token.name_too_long: "Token name is too long"
@@ -90,6 +101,7 @@ user.wechat_id_empty: "WeChat ID is empty!"
user.telegram_id_empty: "Telegram ID is empty!"
user.telegram_not_bound: "This Telegram account is not bound"
user.linux_do_id_empty: "Linux DO ID is empty!"
user.quota_change_zero: "Quota change amount cannot be zero"
# Quota messages
quota.negative: "Quota cannot be negative!"
+13 -1
View File
@@ -3,7 +3,7 @@
# Common messages
common.invalid_params: "无效的参数"
common.database_error: "数据库错误,请稍后重试"
common.database_error: "数据库出错,请联系管理员"
common.retry_later: "请稍后重试"
common.generate_failed: "生成失败"
common.not_found: "未找到"
@@ -22,6 +22,17 @@ common.delete_success: "删除成功"
common.delete_failed: "删除失败"
common.already_exists: "已存在"
common.name_cannot_be_empty: "名称不能为空"
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
# Auth middleware messages
auth.not_logged_in: "无权进行此操作,未登录且未提供 access token"
auth.access_token_invalid: "无权进行此操作,access token 无效"
auth.user_info_invalid: "无权进行此操作,用户信息无效"
auth.user_id_not_provided: "无权进行此操作,未提供 New-Api-User"
auth.user_id_format_error: "无权进行此操作,New-Api-User 格式错误"
auth.user_id_mismatch: "无权进行此操作,New-Api-User 与登录用户不匹配"
auth.user_banned: "用户已被封禁"
auth.insufficient_privilege: "无权进行此操作,权限不足"
# Token messages
token.name_too_long: "令牌名称过长"
@@ -91,6 +102,7 @@ user.wechat_id_empty: "WeChat id 为空!"
user.telegram_id_empty: "Telegram id 为空!"
user.telegram_not_bound: "该 Telegram 账户未绑定"
user.linux_do_id_empty: "Linux DO id 为空!"
user.quota_change_zero: "额度变更量不能为0"
# Quota messages
quota.negative: "额度不能为负数!"
+13 -1
View File
@@ -3,7 +3,7 @@
# Common messages
common.invalid_params: "無效的參數"
common.database_error: "資料庫錯誤,請稍後重試"
common.database_error: "資料庫出錯,請聯繫管理員"
common.retry_later: "請稍後重試"
common.generate_failed: "生成失敗"
common.not_found: "未找到"
@@ -22,6 +22,17 @@ common.delete_success: "刪除成功"
common.delete_failed: "刪除失敗"
common.already_exists: "已存在"
common.name_cannot_be_empty: "名稱不能為空"
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
# Auth middleware messages
auth.not_logged_in: "無權進行此操作,未登入且未提供 access token"
auth.access_token_invalid: "無權進行此操作,access token 無效"
auth.user_info_invalid: "無權進行此操作,使用者資訊無效"
auth.user_id_not_provided: "無權進行此操作,未提供 New-Api-User"
auth.user_id_format_error: "無權進行此操作,New-Api-User 格式錯誤"
auth.user_id_mismatch: "無權進行此操作,New-Api-User 與登入使用者不匹配"
auth.user_banned: "使用者已被封禁"
auth.insufficient_privilege: "無權進行此操作,權限不足"
# Token messages
token.name_too_long: "令牌名稱過長"
@@ -91,6 +102,7 @@ user.wechat_id_empty: "WeChat id 為空!"
user.telegram_id_empty: "Telegram id 為空!"
user.telegram_not_bound: "該 Telegram 帳號未綁定"
user.linux_do_id_empty: "Linux DO id 為空!"
user.quota_change_zero: "額度變更量不能為0"
# Quota messages
quota.negative: "額度不能為負數!"
+54 -17
View File
@@ -1,6 +1,7 @@
package middleware
import (
"errors"
"fmt"
"net"
"net/http"
@@ -9,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
@@ -17,6 +19,7 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func validUserInfo(username string, role int) bool {
@@ -43,17 +46,33 @@ func authHelper(c *gin.Context, minRole int) {
if accessToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,未登录且未提供 access token",
"message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
})
c.Abort()
return
}
user := model.ValidateAccessToken(accessToken)
user, authErr := model.ValidateAccessToken(accessToken)
if authErr != nil {
if errors.Is(authErr, model.ErrDatabase) {
common.SysLog("ValidateAccessToken database error: " + authErr.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
})
}
c.Abort()
return
}
if user != nil && user.Username != "" {
if !validUserInfo(user.Username, user.Role) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,用户信息无效",
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
})
c.Abort()
return
@@ -67,7 +86,7 @@ func authHelper(c *gin.Context, minRole int) {
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,access token 无效",
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
})
c.Abort()
return
@@ -78,7 +97,7 @@ func authHelper(c *gin.Context, minRole int) {
if apiUserIdStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,未提供 New-Api-User",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
})
c.Abort()
return
@@ -87,7 +106,7 @@ func authHelper(c *gin.Context, minRole int) {
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,New-Api-User 格式错误",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
})
c.Abort()
return
@@ -96,7 +115,7 @@ func authHelper(c *gin.Context, minRole int) {
if id != apiUserId {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,New-Api-User 与登录用户不匹配",
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
})
c.Abort()
return
@@ -104,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) {
if status.(int) == common.UserStatusDisabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已被封禁",
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
})
c.Abort()
return
@@ -112,7 +131,7 @@ func authHelper(c *gin.Context, minRole int) {
if role.(int) < minRole {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,权限不足",
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
})
c.Abort()
return
@@ -120,7 +139,7 @@ func authHelper(c *gin.Context, minRole int) {
if !validUserInfo(username.(string), role.(int)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,用户信息无效",
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
})
c.Abort()
return
@@ -198,7 +217,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
if key == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未提供 Authorization 请求头",
"message": common.TranslateMessage(c, i18n.MsgTokenNotProvided),
})
c.Abort()
return
@@ -212,19 +231,28 @@ func TokenAuthReadOnly() func(c *gin.Context) {
token, err := model.GetTokenByKey(key, false)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无效的令牌",
"message": common.TranslateMessage(c, i18n.MsgTokenInvalid),
})
} else {
common.SysLog("TokenAuthReadOnly GetTokenByKey database error: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
}
c.Abort()
return
}
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
c.Abort()
return
@@ -232,7 +260,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
if userCache.Status != common.UserStatusEnabled {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "用户已被封禁",
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
})
c.Abort()
return
@@ -309,7 +337,14 @@ func TokenAuth() func(c *gin.Context) {
}
}
if err != nil {
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
if errors.Is(err, model.ErrDatabase) {
common.SysLog("TokenAuth ValidateUserToken database error: " + err.Error())
abortWithOpenAiMessage(c, http.StatusInternalServerError,
common.TranslateMessage(c, i18n.MsgDatabaseError))
} else {
abortWithOpenAiMessage(c, http.StatusUnauthorized,
common.TranslateMessage(c, i18n.MsgTokenInvalid))
}
return
}
@@ -331,12 +366,14 @@ func TokenAuth() func(c *gin.Context) {
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error())
common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
abortWithOpenAiMessage(c, http.StatusInternalServerError,
common.TranslateMessage(c, i18n.MsgDatabaseError))
return
}
userEnabled := userCache.Status == common.UserStatusEnabled
if !userEnabled {
abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁")
abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned))
return
}
+26
View File
@@ -0,0 +1,26 @@
package model
import "errors"
// Common errors
var (
ErrDatabase = errors.New("database error")
)
// User auth errors
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserEmptyCredentials = errors.New("empty credentials")
)
// Token auth errors
var (
ErrTokenNotProvided = errors.New("token not provided")
ErrTokenInvalid = errors.New("token invalid")
)
// Redemption errors
var ErrRedeemFailed = errors.New("redeem.failed")
// 2FA errors
var ErrTwoFANotEnabled = errors.New("2fa not enabled")
+4 -1
View File
@@ -62,6 +62,7 @@ func InitOptionMap() {
common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = ""
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
@@ -233,7 +234,7 @@ func updateOptionMap(key string, value string) (err error) {
common.ImageDownloadPermission = intValue
}
}
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" {
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
@@ -308,6 +309,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.StopOnSensitiveEnabled = boolValue
case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue
case "SMTPForceAuthLogin":
common.SMTPForceAuthLogin = boolValue
case "WorkerAllowHttpImageRequestEnabled":
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
-4
View File
@@ -12,9 +12,6 @@ import (
"gorm.io/gorm"
)
// ErrRedeemFailed is returned when redemption fails due to database error
var ErrRedeemFailed = errors.New("redeem.failed")
const (
RedemptionTypeQuota = "quota"
RedemptionTypeSubscription = "subscription"
@@ -27,7 +24,6 @@ type RedeemResult struct {
PlanTitle string `json:"plan_title,omitempty"`
SubscriptionId int `json:"subscription_id,omitempty"`
}
type Redemption struct {
Id int `json:"id"`
UserId int `json:"user_id"`
+17 -18
View File
@@ -187,19 +187,14 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
func ValidateUserToken(key string) (token *Token, err error) {
if key == "" {
return nil, errors.New("未提供令牌")
return nil, ErrTokenNotProvided
}
token, err = GetTokenByKey(key, false)
if err == nil {
if token.Status == common.TokenStatusExhausted {
keyPrefix := key[:3]
keySuffix := key[len(key)-3:]
return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]")
} else if token.Status == common.TokenStatusExpired {
return token, errors.New("该令牌已过期")
}
if token.Status != common.TokenStatusEnabled {
return token, errors.New("该令牌状态不可用")
if token.Status == common.TokenStatusExhausted ||
token.Status == common.TokenStatusExpired ||
token.Status != common.TokenStatusEnabled {
return token, ErrTokenInvalid
}
if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
if !common.RedisEnabled {
@@ -209,29 +204,25 @@ func ValidateUserToken(key string) (token *Token, err error) {
common.SysLog("failed to update token status" + err.Error())
}
}
return token, errors.New("该令牌已过期")
return token, ErrTokenInvalid
}
if !token.UnlimitedQuota && token.RemainQuota <= 0 {
if !common.RedisEnabled {
// in this case, we can make sure the token is exhausted
token.Status = common.TokenStatusExhausted
err := token.SelectUpdate()
if err != nil {
common.SysLog("failed to update token status" + err.Error())
}
}
keyPrefix := key[:3]
keySuffix := key[len(key)-3:]
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
return token, ErrTokenInvalid
}
return token, nil
}
common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("无效的令牌")
} else {
return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
return nil, ErrTokenInvalid
}
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
}
func GetTokenByIds(id int, userId int) (*Token, error) {
@@ -481,3 +472,11 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) {
return len(tokens), nil
}
func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
var tokens []Token
err := DB.Select("id", commonKeyCol).
Where("user_id = ? AND id IN (?)", userId, ids).
Find(&tokens).Error
return tokens, err
}
+14
View File
@@ -23,6 +23,8 @@ type TopUp struct {
Status string `json:"status"`
}
var ErrPaymentMethodMismatch = errors.New("payment method mismatch")
func (topUp *TopUp) Insert() error {
var err error
err = DB.Create(topUp).Error
@@ -74,6 +76,10 @@ func Recharge(referenceId string, customerId string) (err error) {
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != "stripe" {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
@@ -325,6 +331,10 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != "creem" {
return ErrPaymentMethodMismatch
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
@@ -396,6 +406,10 @@ func RechargeWaffo(tradeNo string) (err error) {
return errors.New("充值订单不存在")
}
if topUp.PaymentMethod != "waffo" {
return ErrPaymentMethodMismatch
}
if topUp.Status == common.TopUpStatusSuccess {
return nil // 幂等:已成功直接返回
}
-2
View File
@@ -10,8 +10,6 @@ import (
"gorm.io/gorm"
)
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
// TwoFA 用户2FA设置表
type TwoFA struct {
Id int `json:"id" gorm:"primaryKey"`
+10
View File
@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
return quotaDatas, err
}
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
err = DB.Table("quota_data").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("created_at >= ? and created_at <= ?", startTime, endTime).
Group("username, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
if username != "" {
return GetQuotaDataByUsername(username, startTime, endTime)
+23 -14
View File
@@ -523,7 +523,6 @@ func (user *User) Edit(updatePassword bool) error {
"username": newUser.Username,
"display_name": newUser.DisplayName,
"group": newUser.Group,
"quota": newUser.Quota,
"remark": newUser.Remark,
}
if updatePassword {
@@ -598,13 +597,19 @@ func (user *User) ValidateAndFill() (err error) {
password := user.Password
username := strings.TrimSpace(user.Username)
if username == "" || password == "" {
return errors.New("用户名或密码为空")
return ErrUserEmptyCredentials
}
// find by username or email
err = DB.Where("username = ? OR email = ?", username, username).First(user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidCredentials
}
return fmt.Errorf("%w: %v", ErrDatabase, err)
}
// find buy username or email
DB.Where("username = ? OR email = ?", username, username).First(user)
okay := common.ValidatePasswordAndHash(password, user.Password)
if !okay || user.Status != common.UserStatusEnabled {
return errors.New("用户名或密码错误,或用户已被封禁")
return ErrInvalidCredentials
}
return nil
}
@@ -755,16 +760,20 @@ func IsAdmin(userId int) bool {
// return user.Status == common.UserStatusEnabled, nil
//}
func ValidateAccessToken(token string) (user *User) {
func ValidateAccessToken(token string) (*User, error) {
if token == "" {
return nil
return nil, nil
}
token = strings.Replace(token, "Bearer ", "", 1)
user = &User{}
if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 {
return user
user := &User{}
err := DB.Where("access_token = ?", token).First(user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
}
return user, nil
}
// GetUserQuota gets quota from Redis first, falls back to DB if needed
@@ -896,7 +905,7 @@ func increaseUserQuota(id int, quota int) (err error) {
return err
}
func DecreaseUserQuota(id int, quota int) (err error) {
func DecreaseUserQuota(id int, quota int, db bool) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
@@ -906,7 +915,7 @@ func DecreaseUserQuota(id int, quota int) (err error) {
common.SysLog("failed to decrease user quota: " + err.Error())
}
})
if common.BatchUpdateEnabled {
if !db && common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUserQuota, id, -quota)
return nil
}
@@ -928,7 +937,7 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
if delta > 0 {
return IncreaseUserQuota(id, delta, false)
} else {
return DecreaseUserQuota(id, -delta)
return DecreaseUserQuota(id, -delta, false)
}
}
+6
View File
@@ -18,6 +18,7 @@ var awsModelIDMap = map[string]string{
"claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0",
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
"claude-opus-4-7": "anthropic.claude-opus-4-7",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
@@ -91,6 +92,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"ap": true,
"eu": true,
},
"anthropic.claude-opus-4-7": {
"us": true,
"ap": true,
"eu": true,
},
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"us": true,
"ap": true,
+7
View File
@@ -26,6 +26,13 @@ var ModelList = []string{
"claude-opus-4-6-medium",
"claude-opus-4-6-low",
"claude-sonnet-4-6",
"claude-opus-4-7",
"claude-opus-4-7-max",
"claude-opus-4-7-xhigh",
"claude-opus-4-7-high",
"claude-opus-4-7-medium",
"claude-opus-4-7-low",
"claude-opus-4-7-thinking",
}
var ChannelName = "claude"
+59 -91
View File
@@ -1,12 +1,10 @@
package claude
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/QuantumNous/new-api/common"
@@ -46,61 +44,6 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
}
}
func createClaudeFileSource(file *dto.MessageFile) *types.FileSource {
if file == nil || file.FileData == "" {
return nil
}
if strings.HasPrefix(file.FileData, "http://") || strings.HasPrefix(file.FileData, "https://") {
return types.NewURLFileSource(file.FileData)
}
mimeType := ""
if ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.FileName)), "."); ext != "" {
if detected := service.GetMimeTypeByExtension(ext); detected != "application/octet-stream" {
mimeType = detected
}
}
return types.NewBase64FileSource(file.FileData, mimeType)
}
func buildClaudeFileMessage(c *gin.Context, file *dto.MessageFile) (*dto.ClaudeMediaMessage, error) {
source := createClaudeFileSource(file)
if source == nil {
return nil, nil
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting document for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %w", err)
}
switch strings.ToLower(mimeType) {
case "application/pdf":
return &dto.ClaudeMediaMessage{
Type: "document",
Source: &dto.ClaudeMessageSource{
Type: "base64",
MediaType: mimeType,
Data: base64Data,
},
}, nil
case "text/plain":
decodedData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, fmt.Errorf("decode text file data failed: %w", err)
}
return &dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer(string(decodedData)),
}, nil
default:
msg := fmt.Sprintf("claude: skip unsupported file content, filename=%q, mime=%q", file.FileName, mimeType)
if c != nil {
logger.LogInfo(c, msg)
} else {
common.SysLog(msg)
}
return nil, nil
}
}
func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
claudeTools := make([]any, 0, len(textRequest.Tools))
@@ -142,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
// 解析 UserLocation JSON
var userLocationMap map[string]interface{}
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
// 检查是否有 approximate 字段
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
@@ -211,17 +154,35 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
strings.HasPrefix(textRequest.Model, "claude-opus-4-6") {
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) {
claudeRequest.Model = baseModel
claudeRequest.Thinking = &dto.Thinking{
Type: "adaptive",
}
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
claudeRequest.TopP = common.GetPointer[float64](0)
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
claudeRequest.Thinking.Display = "summarized"
claudeRequest.Temperature = nil
claudeRequest.TopP = nil
claudeRequest.TopK = nil
} else {
claudeRequest.TopP = nil
claudeRequest.Temperature = common.GetPointer[float64](1.0)
}
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(textRequest.Model, "-thinking") {
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
claudeRequest.Temperature = nil
claudeRequest.TopP = nil
claudeRequest.TopK = nil
} else {
// 因为BudgetTokens 必须大于1024
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
claudeRequest.MaxTokens = common.GetPointer[uint](1280)
@@ -236,8 +197,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
claudeRequest.TopP = nil
claudeRequest.Temperature = common.GetPointer[float64](1.0)
}
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
claudeRequest.Model = trimmedModel
}
}
@@ -315,7 +277,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
formatMessages = formatMessages[:len(formatMessages)-1]
}
}
if fmtMessage.Content == nil {
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
fmtMessage.SetStringContent("...")
}
formatMessages = append(formatMessages, fmtMessage)
@@ -331,14 +293,16 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
if message.Role == "system" {
// 根据Claude API规范,system字段使用数组格式更有通用性
if message.IsStringContent() {
if text := message.StringContent(); text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](message.StringContent()),
Text: common.GetPointer[string](text),
})
}
} else {
// 支持复合内容的system消息(虽然不常见,但需要考虑完整性)
for _, ctx := range message.ParseContent() {
if ctx.Type == "text" {
if ctx.Type == "text" && ctx.Text != "" {
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](ctx.Text),
@@ -396,54 +360,49 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
}
} else if message.IsStringContent() && message.ToolCalls == nil {
claudeMessage.Content = message.StringContent()
text := message.StringContent()
if text == "" {
text = "..."
}
claudeMessage.Content = text
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
switch mediaMessage.Type {
case "text":
if mediaMessage.Text != "" {
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](mediaMessage.Text),
})
case dto.ContentTypeImageURL:
claudeMediaMessage := dto.ClaudeMediaMessage{
Type: "image",
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
imageUrl := mediaMessage.GetImageMedia()
if imageUrl == nil {
default:
source := mediaMessage.ToFileSource()
if source == nil {
continue
}
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(imageUrl.Url, "http") {
source = types.NewURLFileSource(imageUrl.Url)
} else {
source = types.NewBase64FileSource(imageUrl.Url, "")
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
// FIXME
//case dto.ContentTypeFile:
// claudeFileMessage, err := buildClaudeFileMessage(c, mediaMessage.GetFile())
// if err != nil {
// return nil, err
// }
// if claudeFileMessage != nil {
// claudeMediaMessages = append(claudeMediaMessages, *claudeFileMessage)
// }
default:
continue
}
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any)
@@ -877,7 +836,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
+3 -37
View File
@@ -585,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
Text: part.Text,
})
}
} else if part.Type == dto.ContentTypeImageURL {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
imageUrl := part.GetImageMedia().Url
if strings.HasPrefix(imageUrl, "http") {
source = types.NewURLFileSource(imageUrl)
} else {
source = types.NewBase64FileSource(imageUrl, "")
source := part.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
if err != nil {
@@ -604,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeFile {
if part.GetFile().FileId != "" {
return nil, fmt.Errorf("only base64 file is supported in gemini")
}
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeInputAudio {
if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini")
}
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
+7 -1
View File
@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return request, nil
if info.RelayMode != constant.RelayModeImagesGenerations {
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
}
return oaiImage2MiniMaxImageRequest(request), nil
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.RelayMode == constant.RelayModeAudioSpeech {
return handleTTSResponse(c, resp, info)
}
if info.RelayMode == constant.RelayModeImagesGenerations {
return miniMaxImageHandler(c, resp, info)
}
switch info.RelayFormat {
case types.RelayFormatClaude:
+137
View File
@@ -0,0 +1,137 @@
package minimax
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
)
func TestGetRequestURLForImageGeneration(t *testing.T) {
t.Parallel()
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://api.minimax.chat",
},
}
got, err := GetRequestURL(info)
if err != nil {
t.Fatalf("GetRequestURL returned error: %v", err)
}
want := "https://api.minimax.chat/v1/image_generation"
if got != want {
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
}
}
func TestConvertImageRequest(t *testing.T) {
t.Parallel()
adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
OriginModelName: "image-01",
}
request := dto.ImageRequest{
Model: "image-01",
Prompt: "a red fox in snowfall",
Size: "1536x1024",
ResponseFormat: "url",
N: uintPtr(2),
}
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
if err != nil {
t.Fatalf("ConvertImageRequest returned error: %v", err)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload["model"] != "image-01" {
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
}
if payload["prompt"] != request.Prompt {
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
}
if payload["n"] != float64(2) {
t.Fatalf("n = %#v, want 2", payload["n"])
}
if payload["aspect_ratio"] != "3:2" {
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
}
if payload["response_format"] != "url" {
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
}
}
func TestDoResponseForImageGeneration(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
StartTime: time.Unix(1700000000, 0),
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: httptest.NewRecorder().Result().Body,
}
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
adaptor := &Adaptor{}
usage, err := adaptor.DoResponse(c, resp, info)
if err != nil {
t.Fatalf("DoResponse returned error: %v", err)
}
if usage == nil {
t.Fatalf("DoResponse returned nil usage")
}
body := recorder.Body.String()
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
}
if strings.Contains(body, `"image_urls"`) {
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
}
}
type nopReadCloser struct {
*strings.Reader
}
func (n nopReadCloser) Close() error {
return nil
}
func ioNopCloser(body string) nopReadCloser {
return nopReadCloser{Reader: strings.NewReader(body)}
}
func uintPtr(v uint) *uint {
return &v
}
+4
View File
@@ -8,6 +8,8 @@ var ModelList = []string{
"abab6-chat",
"abab5.5-chat",
"abab5.5s-chat",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"speech-2.5-hd-preview",
"speech-2.5-turbo-preview",
"speech-02-hd",
@@ -19,6 +21,8 @@ var ModelList = []string{
"MiniMax-M2",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"image-01",
"image-01-live",
}
var ChannelName = "minimax"
+213
View File
@@ -0,0 +1,213 @@
package minimax
import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type MiniMaxImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
AspectRatio string `json:"aspect_ratio,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
N int `json:"n,omitempty"`
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
}
type MiniMaxImageResponse struct {
ID string `json:"id"`
Data struct {
ImageURLs []string `json:"image_urls"`
ImageBase64 []string `json:"image_base64"`
} `json:"data"`
Metadata map[string]any `json:"metadata"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
minimaxRequest := MiniMaxImageRequest{
Model: request.Model,
Prompt: request.Prompt,
ResponseFormat: responseFormat,
N: 1,
AigcWatermark: request.Watermark,
}
if request.Model == "" {
minimaxRequest.Model = "image-01"
}
if request.N != nil && *request.N > 0 {
minimaxRequest.N = int(*request.N)
}
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
minimaxRequest.AspectRatio = aspectRatio
}
if raw, ok := request.Extra["prompt_optimizer"]; ok {
var promptOptimizer bool
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
minimaxRequest.PromptOptimizer = &promptOptimizer
}
}
return minimaxRequest
}
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
if raw, ok := request.Extra["aspect_ratio"]; ok {
var aspectRatio string
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
return aspectRatio
}
}
switch request.Size {
case "1024x1024":
return "1:1"
case "1792x1024":
return "16:9"
case "1024x1792":
return "9:16"
case "1536x1024", "1248x832":
return "3:2"
case "1024x1536", "832x1248":
return "2:3"
case "1152x864":
return "4:3"
case "864x1152":
return "3:4"
case "1344x576":
return "21:9"
}
width, height, ok := parseImageSize(request.Size)
if !ok {
return ""
}
ratio := reduceAspectRatio(width, height)
switch ratio {
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
return ratio
default:
return ""
}
}
func parseImageSize(size string) (int, int, bool) {
parts := strings.Split(size, "x")
if len(parts) != 2 {
return 0, 0, false
}
width, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
height, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
if width <= 0 || height <= 0 {
return 0, 0, false
}
return width, height, true
}
func reduceAspectRatio(width, height int) string {
divisor := gcd(width, height)
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
}
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a == 0 {
return 1
}
return a
}
func normalizeMiniMaxResponseFormat(responseFormat string) string {
switch strings.ToLower(responseFormat) {
case "", "url":
return "url"
case "b64_json", "base64":
return "base64"
default:
return responseFormat
}
}
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
imageResponse := &dto.ImageResponse{
Created: info.StartTime.Unix(),
}
for _, imageURL := range response.Data.ImageURLs {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
}
for _, imageBase64 := range response.Data.ImageBase64 {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
}
if len(response.Metadata) > 0 {
metadata, err := common.Marshal(response.Metadata)
if err != nil {
return nil, err
}
imageResponse.Metadata = metadata
}
return imageResponse, nil
}
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
var minimaxResponse MiniMaxImageResponse
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if minimaxResponse.BaseResp.StatusCode != 0 {
return nil, types.WithOpenAIError(types.OpenAIError{
Message: minimaxResponse.BaseResp.StatusMsg,
Type: "minimax_image_error",
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
}, resp.StatusCode)
}
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
jsonResponse, err := common.Marshal(openAIResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
if _, err := c.Writer.Write(jsonResponse); err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
return &dto.Usage{}, nil
}
+2
View File
@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeChatCompletions:
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
case constant.RelayModeAudioSpeech:
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
default:
+2 -9
View File
@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
parts := m.ParseContent()
for _, part := range parts {
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(img.Url, "http") {
source = types.NewURLFileSource(img.Url)
} else {
source = types.NewBase64FileSource(img.Url, "")
}
source := part.ToFileSource()
if source != nil {
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
if err != nil {
return nil, err
+8 -3
View File
@@ -136,8 +136,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
task = "chat/completions" + task
}
// 特殊处理 responses API
if info.RelayMode == relayconstant.RelayModeResponses {
// 特殊处理 responses API(包含 compact
if info.RelayMode == relayconstant.RelayModeResponses || info.RelayMode == relayconstant.RelayModeResponsesCompact {
responsesApiVersion := "preview"
subUrl := "/openai/v1/responses"
@@ -150,6 +150,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion
}
// compact 模式追加 /compact
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
subUrl = subUrl + "/compact"
}
requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion)
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil
}
@@ -369,7 +374,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
a.ResponseFormat = request.ResponseFormat
if info.RelayMode == relayconstant.RelayModeAudioSpeech {
jsonData, err := json.Marshal(request)
jsonData, err := common.Marshal(request)
if err != nil {
return nil, fmt.Errorf("error marshalling object: %w", err)
}
+3 -3
View File
@@ -80,9 +80,9 @@ type AliVideoOutput struct {
// AliUsage 使用统计
type AliUsage struct {
Duration int `json:"duration,omitempty"`
VideoCount int `json:"video_count,omitempty"`
SR int `json:"SR,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
VideoCount dto.IntValue `json:"video_count,omitempty"`
SR dto.IntValue `json:"SR,omitempty"`
}
type AliMetadata struct {
+43
View File
@@ -132,6 +132,49 @@ func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *r
return nil
}
// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
req, err := relaycommon.GetTaskRequest(c)
if err != nil {
return nil
}
if hasVideoInMetadata(req.Metadata) {
if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
return map[string]float64{"video_input": ratio}
}
}
return nil
}
// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
// 避免构建完整的上游 requestPayload。
func hasVideoInMetadata(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
contentRaw, ok := metadata["content"]
if !ok {
return false
}
contentSlice, ok := contentRaw.([]interface{})
if !ok {
return false
}
for _, item := range contentSlice {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if itemMap["type"] == "video_url" {
return true
}
if _, has := itemMap["video_url"]; has {
return true
}
}
return false
}
// BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
req, err := relaycommon.GetTaskRequest(c)
+13
View File
@@ -10,3 +10,16 @@ var ModelList = []string{
}
var ChannelName = "doubao-video"
// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。
// 管理员应将 ModelRatio 设置为"不含视频"的较高费率,
// 系统在检测到视频输入时自动乘以此折扣。
var videoInputRatioMap = map[string]float64{
"doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087
"doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946
}
func GetVideoInputRatio(modelName string) (float64, bool) {
r, ok := videoInputRatioMap[modelName]
return r, ok
}
+1
View File
@@ -44,6 +44,7 @@ var claudeModelMap = map[string]string{
"claude-haiku-4-5-20251001": "claude-haiku-4-5@20251001",
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
"claude-opus-4-6": "claude-opus-4-6",
"claude-opus-4-7": "claude-opus-4-7",
}
const anthropicVersion = "vertex-2023-10-16"
+3
View File
@@ -64,6 +64,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
case relayconstant.RelayModeImagesGenerations:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
}
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
default:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
+20 -1
View File
@@ -53,17 +53,35 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
strings.HasPrefix(request.Model, "claude-opus-4-6") {
(strings.HasPrefix(request.Model, "claude-opus-4-6") || strings.HasPrefix(request.Model, "claude-opus-4-7")) {
request.Model = baseModel
request.Thinking = &dto.Thinking{
Type: "adaptive",
}
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
request.Thinking.Display = "summarized"
request.Temperature = nil
request.TopP = nil
request.TopK = nil
} else {
request.Temperature = common.GetPointer[float64](1.0)
}
info.UpstreamModelName = request.Model
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(request.Model, "-thinking") {
if request.Thinking == nil {
baseModel := strings.TrimSuffix(request.Model, "-thinking")
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
request.Temperature = nil
request.TopP = nil
request.TopK = nil
} else {
// 因为BudgetTokens 必须大于1024
if request.MaxTokens == nil || *request.MaxTokens < 1280 {
request.MaxTokens = common.GetPointer[uint](1280)
@@ -78,6 +96,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
request.Temperature = common.GetPointer[float64](1.0)
}
}
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
request.Model = strings.TrimSuffix(request.Model, "-thinking")
}
+1
View File
@@ -32,6 +32,7 @@ var paramOverrideKeyAuditPaths = map[string]struct{}{
"upstream_model": {},
"service_tier": {},
"inference_geo": {},
"speed": {},
}
type paramOverrideAuditRecorder struct {
+19 -1
View File
@@ -2038,6 +2038,8 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
input := `{
"service_tier":"flex",
"inference_geo":"eu",
"speed":"fast",
"cache_control":{"type":"ephemeral"},
"safety_identifier":"user-123",
"store":true,
"stream_options":{"include_obfuscation":false}
@@ -2048,7 +2050,7 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
if err != nil {
t.Fatalf("RemoveDisabledFields returned error: %v", err)
}
assertJSONEqual(t, `{"store":true}`, string(out))
assertJSONEqual(t, `{"cache_control":{"type":"ephemeral"},"store":true}`, string(out))
}
func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
@@ -2067,6 +2069,22 @@ func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
assertJSONEqual(t, `{"inference_geo":"eu","store":true}`, string(out))
}
func TestRemoveDisabledFieldsAllowSpeed(t *testing.T) {
input := `{
"speed":"fast",
"store":true
}`
settings := dto.ChannelOtherSettings{
AllowSpeed: true,
}
out, err := RemoveDisabledFields([]byte(input), settings, false)
if err != nil {
t.Fatalf("RemoveDisabledFields returned error: %v", err)
}
assertJSONEqual(t, `{"speed":"fast","store":true}`, string(out))
}
func TestApplyParamOverrideWithRelayInfoRecordsOperationAuditInDebugMode(t *testing.T) {
originalDebugEnabled := common2.DebugEnabled
common2.DebugEnabled = true
+25
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -437,6 +438,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
if request != nil {
isStream = request.IsStream(c)
}
c.Set(string(constant.ContextKeyIsStream), isStream)
// firstResponseTime = time.Now() - 1 second
@@ -690,6 +692,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
type Alias TaskSubmitReq
aux := &struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
Duration json.RawMessage `json:"duration,omitempty"`
*Alias
}{
Alias: (*Alias)(t),
@@ -699,6 +702,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return err
}
if len(aux.Duration) > 0 {
var durationInt int
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
t.Duration = durationInt
} else {
var durationStr string
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
if v, err := strconv.Atoi(durationStr); err == nil {
t.Duration = v
}
}
}
}
if len(aux.Metadata) > 0 {
var metadataStr string
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
@@ -754,6 +771,7 @@ func FailTaskInfo(reason string) *TaskInfo {
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持)
// inference_geo: Claude 数据驻留推理区域字段(仅 Claude 支持,默认过滤)
// speed: Claude 推理速度模式字段(仅 Claude 支持,默认过滤)
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
// stream_options.include_obfuscation: 响应流混淆控制字段(仅 OpenAI Responses API 支持)
@@ -782,6 +800,13 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
}
}
// 默认移除 speed,除非明确允许(避免意外切换 Claude 推理速度模式)
if !channelOtherSettings.AllowSpeed {
if _, exists := data["speed"]; exists {
delete(data, "speed")
}
}
// 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
if channelOtherSettings.DisableStore {
if _, exists := data["store"]; exists {
+3 -1
View File
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if err != nil {
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
}
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
}
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
+44 -14
View File
@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -13,6 +14,21 @@ import (
"github.com/gin-gonic/gin"
)
func modelPriceNotConfiguredError(modelName string, userId int) error {
if model.IsAdmin(userId) {
return fmt.Errorf(
"模型 %s 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;"+
"Model %s price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing.",
modelName, modelName,
)
}
return fmt.Errorf(
"模型 %s 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;"+
"Model %s has not been priced by the administrator yet. Please contact the site administrator to enable this model.",
modelName, modelName,
)
}
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
const claudeCacheCreation1hMultiplier = 6 / 3.75
@@ -75,7 +91,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
acceptUnsetRatio = true
}
if !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
}
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
@@ -139,47 +155,61 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return priceData, nil
}
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
groupRatioInfo := HandleGroupRatio(c, info)
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
// 如果没有配置价格,检查模型倍率配置
if !success {
usePrice := success
var modelRatio float64
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if ok {
modelPrice = defaultPrice
usePrice = true
} else {
// 没有配置倍率也不接受没配置,那就返回错误
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
var ratioSuccess bool
var matchName string
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true
}
if !ratioSuccess && !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
}
// 未配置价格但配置了倍率,使用默认预扣价格
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
}
}
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
// 免费模型检测(与 ModelPriceHelper 对齐)
var quota int
freeModel := false
if usePrice {
quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
}
}
} else {
// 按量计费:以模型倍率的一半作为预扣额度
quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
modelPrice = -1
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
quota = 0
freeModel = true
}
}
}
priceData := types.PriceData{
FreeModel: freeModel,
ModelPrice: modelPrice,
ModelRatio: modelRatio,
UsePrice: usePrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
}
+1 -1
View File
@@ -143,7 +143,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
info.OriginModelName = originModelName
info.PriceData = originPriceData
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry(), types.ErrOptionWithStatusCode(http.StatusBadRequest))
}
service.PostTextConsumeQuota(c, info, usageDto, nil)
+2
View File
@@ -257,6 +257,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken)
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
}
usageRoute := apiRouter.Group("/usage")
@@ -292,6 +293,7 @@ func SetApiRouter(router *gin.Engine) {
dataRoute := apiRouter.Group("/data")
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
+1 -38
View File
@@ -2,11 +2,9 @@ package service
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -44,7 +42,7 @@ func EnableChannel(channelId int, usingKey string, channelName string) {
}
}
func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
func ShouldDisableChannel(err *types.NewAPIError) bool {
if !common.AutomaticDisableChannelEnabled {
return false
}
@@ -60,41 +58,6 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
if operation_setting.ShouldDisableByStatusCode(err.StatusCode) {
return true
}
//if err.StatusCode == http.StatusUnauthorized {
// return true
//}
if err.StatusCode == http.StatusForbidden {
switch channelType {
case constant.ChannelTypeGemini:
return true
}
}
oaiErr := err.ToOpenAIError()
switch oaiErr.Code {
case "invalid_api_key":
return true
case "account_deactivated":
return true
case "billing_not_active":
return true
case "pre_consume_token_quota_failed":
return true
case "Arrearage":
return true
}
switch oaiErr.Type {
case "insufficient_quota":
return true
case "insufficient_user_quota":
return true
// https://docs.anthropic.com/claude/reference/errors
case "authentication_error":
return true
case "permission_error":
return true
case "forbidden":
return true
}
lowerMessage := strings.ToLower(err.Error())
search, _ := AcSearch(lowerMessage, operation_setting.AutomaticDisableKeywords, true)
+17 -4
View File
@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
unknown++
continue
}
if rule.IncludeUsingGroup {
if rule.IncludeModelName {
if len(parts) < 3 {
unknown++
continue
}
}
if rule.IncludeUsingGroup {
minParts := 3
if rule.IncludeModelName {
minParts = 4
}
if len(parts) < minParts {
unknown++
continue
}
}
byRuleName[ruleName]++
}
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
}
}
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 3)
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 4)
if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name)
}
if rule.IncludeModelName && modelName != "" {
parts = append(parts, modelName)
}
if rule.IncludeUsingGroup && usingGroup != "" {
parts = append(parts, usingGroup)
}
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
+1 -1
View File
@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
+50 -29
View File
@@ -25,14 +25,26 @@ import (
// FileService 统一的文件处理服务
// 提供文件下载、解码、缓存等功能的统一入口
// getContextCacheKey 生成 context 缓存的 key
// getContextCacheKey 生成 URL context 缓存的 key
func getContextCacheKey(url string) string {
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
}
// getBase64ContextCacheKey 生成 base64 context 缓存的 key
// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash
func getBase64ContextCacheKey(data string, mimeType string) string {
keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType)
if len(data) > 128 {
keyMaterial += data[:128]
} else {
keyMaterial += data
}
return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial))
}
// LoadFileSource 加载文件源数据
// 这是统一的入口,会自动处理缓存和不同的来源类型
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) {
if source == nil {
return nil, fmt.Errorf("file source is nil")
}
@@ -43,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
// 1. 快速检查内部缓存
if source.HasCache() {
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
if c != nil {
registerSourceForCleanup(c, source)
}
@@ -62,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
return source.GetCache(), nil
}
// 4. 如果是 URL,检查 Context 缓存
// 4. 根据来源类型加载(含 URL context 缓存查找)
var cachedData *types.CachedFileData
var contextKey string
if source.IsURL() && c != nil {
contextKey = getContextCacheKey(source.URL)
if cachedData, exists := c.Get(contextKey); exists {
data := cachedData.(*types.CachedFileData)
var err error
switch s := source.(type) {
case *types.URLSource:
if c != nil {
contextKey = getContextCacheKey(s.URL)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
// 5. 执行加载逻辑
var cachedData *types.CachedFileData
var err error
if source.IsURL() {
cachedData, err = loadFromURL(c, source.URL, reason...)
} else {
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
cachedData, err = loadFromURL(c, s.URL, reason...)
case *types.Base64Source:
if c != nil {
contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
cachedData, err = loadFromBase64(s.Base64Data, s.MimeType)
default:
return nil, fmt.Errorf("unsupported file source type: %T", source)
}
if err != nil {
return nil, err
}
// 6. 设置缓存
// 5. 设置缓存
source.SetCache(cachedData)
if contextKey != "" && c != nil {
c.Set(contextKey, cachedData)
}
// 7. 注册到 context 以便请求结束时自动清理
// 6. 注册到 context 以便请求结束时自动清理
if c != nil {
registerSourceForCleanup(c, source)
}
@@ -103,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
}
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
if source.IsRegistered() {
return
}
key := string(constant.ContextKeyFileSourcesToCleanup)
var sources []*types.FileSource
var sources []types.FileSource
if existing, exists := c.Get(key); exists {
sources = existing.([]*types.FileSource)
sources = existing.([]types.FileSource)
}
sources = append(sources, source)
c.Set(key, sources)
@@ -123,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func CleanupFileSources(c *gin.Context) {
key := string(constant.ContextKeyFileSourcesToCleanup)
if sources, exists := c.Get(key); exists {
for _, source := range sources.([]*types.FileSource) {
for _, source := range sources.([]types.FileSource) {
if cache := source.GetCache(); cache != nil {
cache.Close()
}
}
c.Set(key, nil) // 清除引用
c.Set(key, nil)
}
}
@@ -363,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached
}
// GetImageConfig 获取图片配置
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) {
cachedData, err := LoadFileSource(c, source, "get_image_config")
if err != nil {
return image.Config{}, "", err
@@ -394,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str
}
// GetBase64Data 获取 base64 编码的数据
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
cachedData, err := LoadFileSource(c, source, reason...)
if err != nil {
return "", "", err
@@ -407,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (
}
// GetMimeType 获取文件的 MIME 类型
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
func GetMimeType(c *gin.Context, source types.FileSource) (string, error) {
if source.HasCache() {
return source.GetCache().MimeType, nil
}
if source.IsURL() {
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
if urlSource, ok := source.(*types.URLSource); ok {
mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
return mimeType, nil
}
+2 -2
View File
@@ -37,7 +37,7 @@ func (w *WalletFunding) PreConsume(amount int) error {
if amount <= 0 {
return nil
}
if err := model.DecreaseUserQuota(w.userId, amount); err != nil {
if err := model.DecreaseUserQuota(w.userId, amount, false); err != nil {
return err
}
w.consumed = amount
@@ -49,7 +49,7 @@ func (w *WalletFunding) Settle(delta int) error {
return nil
}
if delta > 0 {
return model.DecreaseUserQuota(w.userId, delta)
return model.DecreaseUserQuota(w.userId, delta, false)
}
return model.IncreaseUserQuota(w.userId, -delta, false)
}
+1 -1
View File
@@ -381,7 +381,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
} else {
// Wallet
if quota > 0 {
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
err = model.DecreaseUserQuota(relayInfo.UserId, quota, false)
} else {
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
}
+21 -5
View File
@@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
}
}
other := make(map[string]interface{})
other["is_task"] = true
other["request_path"] = c.Request.URL.Path
other["model_price"] = info.PriceData.ModelPrice
if info.PriceData.ModelRatio > 0 {
other["model_ratio"] = info.PriceData.ModelRatio
}
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
@@ -86,7 +90,7 @@ func taskAdjustFunding(task *model.Task, delta int) error {
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
}
if delta > 0 {
return model.DecreaseUserQuota(task.UserId, delta)
return model.DecreaseUserQuota(task.UserId, delta, false)
}
return model.IncreaseUserQuota(task.UserId, -delta, false)
}
@@ -117,6 +121,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} {
other := make(map[string]interface{})
if bc := task.PrivateData.BillingContext; bc != nil {
other["model_price"] = bc.ModelPrice
if bc.ModelRatio > 0 {
other["model_ratio"] = bc.ModelRatio
}
other["group_ratio"] = bc.GroupRatio
if len(bc.OtherRatios) > 0 {
for k, v := range bc.OtherRatios {
@@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
}
other := taskBillingOther(task)
other["task_id"] = task.TaskID
//other["reason"] = reason
other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
@@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
finalGroupRatio = groupRatio
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
// 计算 OtherRatios 乘积(视频折扣、时长等)
otherMultiplier := 1.0
if bc := task.PrivateData.BillingContext; bc != nil {
for _, r := range bc.OtherRatios {
if r != 1.0 && r > 0 {
otherMultiplier *= r
}
}
}
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
RecalculateTaskQuota(ctx, task, actualQuota, reason)
}
-3
View File
@@ -100,8 +100,6 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
if err != nil {
return 0, err
}
fileMeta.MimeType = format
if config.Width == 0 || config.Height == 0 {
// not an image, but might be a valid file
if format != "" {
@@ -268,7 +266,6 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
}
continue
}
file.MimeType = cachedData.MimeType
file.FileType = DetectFileType(cachedData.MimeType)
}
}
@@ -20,9 +20,10 @@ type ChannelAffinityRule struct {
ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure"`
IncludeUsingGroup bool `json:"include_using_group"`
IncludeModelName bool `json:"include_model_name"`
IncludeRuleName bool `json:"include_rule_name"`
}
+14
View File
@@ -64,6 +64,13 @@ var defaultCacheRatio = map[string]float64{
"claude-opus-4-6-high": 0.1,
"claude-opus-4-6-medium": 0.1,
"claude-opus-4-6-low": 0.1,
"claude-opus-4-7": 0.1,
"claude-opus-4-7-thinking": 0.1,
"claude-opus-4-7-max": 0.1,
"claude-opus-4-7-xhigh": 0.1,
"claude-opus-4-7-high": 0.1,
"claude-opus-4-7-medium": 0.1,
"claude-opus-4-7-low": 0.1,
}
var defaultCreateCacheRatio = map[string]float64{
@@ -92,6 +99,13 @@ var defaultCreateCacheRatio = map[string]float64{
"claude-opus-4-6-high": 1.25,
"claude-opus-4-6-medium": 1.25,
"claude-opus-4-6-low": 1.25,
"claude-opus-4-7": 1.25,
"claude-opus-4-7-thinking": 1.25,
"claude-opus-4-7-max": 1.25,
"claude-opus-4-7-xhigh": 1.25,
"claude-opus-4-7-high": 1.25,
"claude-opus-4-7-medium": 1.25,
"claude-opus-4-7-low": 1.25,
}
//var defaultCreateCacheRatio = map[string]float64{}
+10 -4
View File
@@ -146,6 +146,12 @@ var defaultModelRatio = map[string]float64{
"claude-opus-4-6-high": 2.5,
"claude-opus-4-6-medium": 2.5,
"claude-opus-4-6-low": 2.5,
"claude-opus-4-7": 2.5,
"claude-opus-4-7-max": 2.5,
"claude-opus-4-7-xhigh": 2.5,
"claude-opus-4-7-high": 2.5,
"claude-opus-4-7-medium": 2.5,
"claude-opus-4-7-low": 2.5,
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
"claude-opus-4-20250514": 7.5,
"claude-opus-4-1-20250805": 7.5,
@@ -361,6 +367,10 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
func GetModelPrice(name string, printErr bool) (float64, bool) {
name = FormatMatchingModelName(name)
if price, ok := modelPriceMap.Get(name); ok {
return price, true
}
if strings.HasSuffix(name, CompactModelSuffix) {
price, ok := modelPriceMap.Get(CompactWildcardModelKey)
if !ok {
@@ -372,14 +382,10 @@ func GetModelPrice(name string, printErr bool) (float64, bool) {
return price, true
}
price, ok := modelPriceMap.Get(name)
if !ok {
if printErr {
common.SysError("model price not found: " + name)
}
return -1, false
}
return price, true
}
func UpdateModelRatioByJSONString(jsonStr string) error {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/samber/lo"
)
var EffortSuffixes = []string{"-max", "-high", "-medium", "-low", "-minimal"}
var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
// TrimEffortSuffix -> modelName level(low) exists
func TrimEffortSuffix(modelName string) (string, string, bool) {
+6
View File
@@ -390,6 +390,12 @@ func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions {
}
}
func ErrOptionWithStatusCode(statusCode int) NewAPIErrorOptions {
return func(e *NewAPIError) {
e.StatusCode = statusCode
}
}
func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions {
return func(e *NewAPIError) {
if common.DebugEnabled {
+128 -127
View File
@@ -4,39 +4,144 @@ import (
"fmt"
"image"
"os"
"strings"
"sync"
)
// FileSourceType 文件来源类型
type FileSourceType string
const (
FileSourceTypeURL FileSourceType = "url" // URL 来源
FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据
)
// FileSource 统一的文件来源抽象
// FileSource 统一的文件来源抽象接口
// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
type FileSource struct {
Type FileSourceType `json:"type"` // 来源类型
URL string `json:"url,omitempty"` // URL(当 Type 为 url 时)
Base64Data string `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时)
MimeType string `json:"mime_type,omitempty"` // MIME 类型(可选,会自动检测)
type FileSource interface {
IsURL() bool
GetIdentifier() string
GetRawData() string
ClearRawData()
// 内部缓存(不导出,不序列化)
SetCache(data *CachedFileData)
GetCache() *CachedFileData
HasCache() bool
ClearCache()
IsRegistered() bool
SetRegistered(registered bool)
Mu() *sync.Mutex
}
// baseFileSource 共享的缓存/锁/清理注册状态
type baseFileSource struct {
cachedData *CachedFileData
cacheLoaded bool
registered bool // 是否已注册到清理列表
mu sync.Mutex // 保护加载过程
registered bool
mu sync.Mutex
}
// Mu 获取内部锁
func (f *FileSource) Mu() *sync.Mutex {
return &f.mu
func (b *baseFileSource) SetCache(data *CachedFileData) {
b.cachedData = data
b.cacheLoaded = true
}
// CachedFileData 缓存的文件数据
// 支持内存缓存和磁盘缓存两种模式
func (b *baseFileSource) GetCache() *CachedFileData {
return b.cachedData
}
func (b *baseFileSource) HasCache() bool {
return b.cacheLoaded && b.cachedData != nil
}
func (b *baseFileSource) ClearCache() {
if b.cachedData != nil {
b.cachedData.Close()
}
b.cachedData = nil
b.cacheLoaded = false
}
func (b *baseFileSource) IsRegistered() bool {
return b.registered
}
func (b *baseFileSource) SetRegistered(registered bool) {
b.registered = registered
}
func (b *baseFileSource) Mu() *sync.Mutex {
return &b.mu
}
// ---------------------------------------------------------------------------
// URLSource — URL 来源的 FileSource 实现
// ---------------------------------------------------------------------------
type URLSource struct {
baseFileSource
URL string
}
func (u *URLSource) IsURL() bool { return true }
func (u *URLSource) GetIdentifier() string {
if len(u.URL) > 100 {
return u.URL[:100] + "..."
}
return u.URL
}
func (u *URLSource) GetRawData() string { return u.URL }
func (u *URLSource) ClearRawData() {}
// ---------------------------------------------------------------------------
// Base64Source — Base64 内联数据来源的 FileSource 实现
// ---------------------------------------------------------------------------
type Base64Source struct {
baseFileSource
Base64Data string
MimeType string
}
func (b *Base64Source) IsURL() bool { return false }
func (b *Base64Source) GetIdentifier() string {
if len(b.Base64Data) > 50 {
return "base64:" + b.Base64Data[:50] + "..."
}
return "base64:" + b.Base64Data
}
func (b *Base64Source) GetRawData() string { return b.Base64Data }
func (b *Base64Source) ClearRawData() {
if len(b.Base64Data) > 1024 {
b.Base64Data = ""
}
}
// ---------------------------------------------------------------------------
// Constructors
// ---------------------------------------------------------------------------
func NewURLFileSource(url string) *URLSource {
return &URLSource{URL: url}
}
func NewBase64FileSource(base64Data string, mimeType string) *Base64Source {
return &Base64Source{
Base64Data: base64Data,
MimeType: mimeType,
}
}
func NewFileSourceFromData(data string, mimeType string) FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return NewURLFileSource(data)
}
return NewBase64FileSource(data, mimeType)
}
// ---------------------------------------------------------------------------
// CachedFileData — 缓存的文件数据(支持内存和磁盘两种模式)
// ---------------------------------------------------------------------------
type CachedFileData struct {
base64Data string // 内存中的 base64 数据(小文件)
MimeType string // MIME 类型
@@ -45,18 +150,15 @@ type CachedFileData struct {
ImageConfig *image.Config // 图片配置(如果是图片)
ImageFormat string // 图片格式(如果是图片)
// 磁盘缓存相关
diskPath string // 磁盘缓存文件路径(大文件)
isDisk bool // 是否使用磁盘缓存
diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除)
diskClosed bool // 是否已关闭/清理
statDecremented bool // 是否已扣减统计
// 统计回调,避免循环依赖
OnClose func(size int64)
}
// NewMemoryCachedData 创建内存缓存的数据
func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{
base64Data: base64Data,
@@ -66,7 +168,6 @@ func NewMemoryCachedData(base64Data string, mimeType string, size int64) *Cached
}
}
// NewDiskCachedData 创建磁盘缓存的数据
func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{
diskPath: diskPath,
@@ -76,7 +177,6 @@ func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFile
}
}
// GetBase64Data 获取 base64 数据(自动处理内存/磁盘)
func (c *CachedFileData) GetBase64Data() (string, error) {
if !c.isDisk {
return c.base64Data, nil
@@ -89,7 +189,6 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
return "", fmt.Errorf("disk cache already closed")
}
// 从磁盘读取
data, err := os.ReadFile(c.diskPath)
if err != nil {
return "", fmt.Errorf("failed to read from disk cache: %w", err)
@@ -97,22 +196,19 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
return string(data), nil
}
// SetBase64Data 设置 base64 数据(仅用于内存模式)
func (c *CachedFileData) SetBase64Data(data string) {
if !c.isDisk {
c.base64Data = data
}
}
// IsDisk 是否使用磁盘缓存
func (c *CachedFileData) IsDisk() bool {
return c.isDisk
}
// Close 关闭并清理资源
func (c *CachedFileData) Close() error {
if !c.isDisk {
c.base64Data = "" // 释放内存
c.base64Data = ""
return nil
}
@@ -126,7 +222,6 @@ func (c *CachedFileData) Close() error {
c.diskClosed = true
if c.diskPath != "" {
err := os.Remove(c.diskPath)
// 只有在删除成功且未扣减过统计时,才执行回调
if err == nil && !c.statDecremented && c.OnClose != nil {
c.OnClose(c.DiskSize)
c.statDecremented = true
@@ -135,97 +230,3 @@ func (c *CachedFileData) Close() error {
}
return nil
}
// NewURLFileSource 创建 URL 来源的 FileSource
func NewURLFileSource(url string) *FileSource {
return &FileSource{
Type: FileSourceTypeURL,
URL: url,
}
}
// NewBase64FileSource 创建 base64 来源的 FileSource
func NewBase64FileSource(base64Data string, mimeType string) *FileSource {
return &FileSource{
Type: FileSourceTypeBase64,
Base64Data: base64Data,
MimeType: mimeType,
}
}
// IsURL 判断是否是 URL 来源
func (f *FileSource) IsURL() bool {
return f.Type == FileSourceTypeURL
}
// IsBase64 判断是否是 base64 来源
func (f *FileSource) IsBase64() bool {
return f.Type == FileSourceTypeBase64
}
// GetIdentifier 获取文件标识符(用于日志和错误追踪)
func (f *FileSource) GetIdentifier() string {
if f.IsURL() {
if len(f.URL) > 100 {
return f.URL[:100] + "..."
}
return f.URL
}
if len(f.Base64Data) > 50 {
return "base64:" + f.Base64Data[:50] + "..."
}
return "base64:" + f.Base64Data
}
// GetRawData 获取原始数据(URL 或完整的 base64 字符串)
func (f *FileSource) GetRawData() string {
if f.IsURL() {
return f.URL
}
return f.Base64Data
}
// SetCache 设置缓存数据
func (f *FileSource) SetCache(data *CachedFileData) {
f.cachedData = data
f.cacheLoaded = true
}
// IsRegistered 是否已注册到清理列表
func (f *FileSource) IsRegistered() bool {
return f.registered
}
// SetRegistered 设置注册状态
func (f *FileSource) SetRegistered(registered bool) {
f.registered = registered
}
// GetCache 获取缓存数据
func (f *FileSource) GetCache() *CachedFileData {
return f.cachedData
}
// HasCache 是否有缓存
func (f *FileSource) HasCache() bool {
return f.cacheLoaded && f.cachedData != nil
}
// ClearCache 清除缓存,释放内存和磁盘文件
func (f *FileSource) ClearCache() {
// 如果有缓存数据,先关闭它(会清理磁盘文件)
if f.cachedData != nil {
f.cachedData.Close()
}
f.cachedData = nil
f.cacheLoaded = false
}
// ClearRawData 清除原始数据,只保留必要的元信息
// 用于在处理完成后释放大文件的内存
func (f *FileSource) ClearRawData() {
// 保留 URL(通常很短),只清除大的 base64 数据
if f.IsBase64() && len(f.Base64Data) > 1024 {
f.Base64Data = ""
}
}
+3 -4
View File
@@ -32,13 +32,12 @@ type TokenCountMeta struct {
type FileMeta struct {
FileType
MimeType string
Source *FileSource // 统一的文件来源(URL 或 base64)
Source FileSource // 统一的文件来源(URL 或 base64)
Detail string // 图片细节级别(low/high/auto
}
// NewFileMeta 创建新的 FileMeta
func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
func NewFileMeta(fileType FileType, source FileSource) *FileMeta {
return &FileMeta{
FileType: fileType,
Source: source,
@@ -46,7 +45,7 @@ func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
}
// NewImageFileMeta 创建图片类型的 FileMeta
func NewImageFileMeta(source *FileSource, detail string) *FileMeta {
func NewImageFileMeta(source FileSource, detail string) *FileMeta {
return &FileMeta{
FileType: FileTypeImage,
Source: source,
+5 -5
View File
@@ -10,7 +10,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.12.0",
"axios": "1.15.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
@@ -776,7 +776,7 @@
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="],
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
@@ -1104,13 +1104,13 @@
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
@@ -1656,7 +1656,7 @@
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+1 -1
View File
@@ -11,7 +11,7 @@
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"antd": "^5.23.0",
"axios": "1.13.5",
"axios": "1.15.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
@@ -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, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { API, showError } from '../../../helpers';
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
const { Title } = Typography;
@@ -28,7 +28,7 @@ import {
import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../markdown/MarkdownRenderer';
// URL
// Check whether content is a URL.
const isUrl = (content) => {
try {
new URL(content.trim());
@@ -38,27 +38,23 @@ const isUrl = (content) => {
}
};
// HTML
// Check whether content contains HTML.
const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false;
// HTML
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
return htmlTagRegex.test(content);
};
// HTML
// Parse HTML content and extract inline styles.
const sanitizeHtml = (html) => {
// HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
//
const styles = Array.from(tempDiv.querySelectorAll('style'))
.map((style) => style.innerHTML)
.join('\n');
// bodybody使
const bodyContent = tempDiv.querySelector('body');
const content = bodyContent ? bodyContent.innerHTML : html;
@@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { t } = useTranslation();
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [htmlStyles, setHtmlStyles] = useState('');
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
const loadContent = async () => {
//
const cachedContent = localStorage.getItem(cacheKey) || '';
if (cachedContent) {
setContent(cachedContent);
processContent(cachedContent);
setLoading(false);
}
@@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { success, message, data } = res.data;
if (success && data) {
setContent(data);
processContent(data);
localStorage.setItem(cacheKey, data);
} else {
if (!cachedContent) {
@@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
}
};
const processContent = (rawContent) => {
if (isHtmlContent(rawContent)) {
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
setProcessedHtmlContent(htmlContent);
setHtmlStyles(styles);
} else {
setProcessedHtmlContent('');
setHtmlStyles('');
const htmlPayload = useMemo(() => {
if (!isHtmlContent(content)) {
return { content: '', styles: '' };
}
};
return sanitizeHtml(content);
}, [content]);
useEffect(() => {
loadContent();
@@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// HTML
useEffect(() => {
const styleId = `document-renderer-styles-${cacheKey}`;
const { styles } = htmlPayload;
if (htmlStyles) {
if (styles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
@@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
styleEl.innerHTML = styles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
@@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const el = document.getElementById(styleId);
if (el) el.remove();
};
}, [htmlStyles, cacheKey]);
}, [cacheKey, htmlPayload]);
//
if (loading) {
@@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// HTML
if (isHtmlContent(content)) {
const { content: htmlContent, styles } = sanitizeHtml(content);
//
useEffect(() => {
if (styles && styles !== htmlStyles) {
setHtmlStyles(styles);
}
}, [content, styles, htmlStyles]);
return (
<div className='min-h-screen bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
</Title>
<div
className='prose prose-lg max-w-none'
dangerouslySetInnerHTML={{ __html: htmlContent }}
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
/>
</div>
</div>
@@ -0,0 +1,52 @@
import React from 'react';
import { Empty, Button } from '@douyinfe/semi-ui';
import {
IllustrationFailure,
IllustrationFailureDark,
} from '@douyinfe/semi-illustrations';
import { withTranslation } from 'react-i18next';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
}
render() {
if (this.state.hasError) {
const { t } = this.props;
return (
<div className='flex flex-col justify-center items-center h-screen p-8'>
<Empty
image={
<IllustrationFailure style={{ width: 250, height: 250 }} />
}
darkModeImage={
<IllustrationFailureDark style={{ width: 250, height: 250 }} />
}
description={t('页面渲染出错,请刷新页面重试')}
/>
<Button
theme='solid'
type='primary'
style={{ marginTop: 16 }}
onClick={() => window.location.reload()}
>
{t('刷新页面')}
</Button>
</div>
);
}
return this.props.children;
}
}
export default withTranslation()(ErrorBoundary);
+10 -3
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
import { Server, Gauge, ExternalLink } from 'lucide-react';
import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
import {
IllustrationConstruction,
IllustrationConstructionDark,
@@ -87,11 +87,18 @@ const ApiInfoPanel = ({
</Tag>
</div>
</div>
<div
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
<div className='flex items-center gap-1 mb-1'>
<span
className='!text-semi-color-primary break-all cursor-pointer hover:underline'
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
</span>
<Copy
size={14}
className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
onClick={() => handleCopyUrl(api.url)}
/>
</div>
<div className='text-gray-500'>{api.description}</div>
</div>
+16 -1
View File
@@ -29,6 +29,9 @@ const ChartsPanel = ({
spec_model_line,
spec_pie,
spec_rank_bar,
spec_user_rank,
spec_user_trend,
isAdminUser,
CARD_PROPS,
CHART_CONFIG,
FLEX_CENTER_GAP2,
@@ -51,9 +54,15 @@ const ChartsPanel = ({
onChange={setActiveChartTab}
>
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
)}
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
)}
</Tabs>
</div>
}
@@ -72,6 +81,12 @@ const ChartsPanel = ({
{activeChartTab === '4' && (
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
)}
{activeChartTab === '5' && isAdminUser && (
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
)}
{activeChartTab === '6' && isAdminUser && (
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
)}
</div>
</Card>
);
+15
View File
@@ -86,12 +86,22 @@ const Dashboard = () => {
);
// ========== ==========
const loadUserData = async () => {
if (dashboardData.isAdminUser) {
const userData = await dashboardData.loadUserQuotaData();
if (userData && userData.length > 0) {
dashboardCharts.updateUserChartData(userData);
}
}
};
const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
});
await loadUserData();
await dashboardData.loadUptimeData();
};
@@ -100,10 +110,12 @@ const Dashboard = () => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
await loadUserData();
};
const handleSearchConfirm = async () => {
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
await loadUserData();
};
// ========== ==========
@@ -182,6 +194,9 @@ const Dashboard = () => {
spec_model_line={dashboardCharts.spec_model_line}
spec_pie={dashboardCharts.spec_pie}
spec_rank_bar={dashboardCharts.spec_rank_bar}
spec_user_rank={dashboardCharts.spec_user_rank}
spec_user_trend={dashboardCharts.spec_user_trend}
isAdminUser={dashboardData.isAdminUser}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
+3
View File
@@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
import App from '../../App';
import FooterBar from './Footer';
import { ToastContainer } from 'react-toastify';
import ErrorBoundary from '../common/ErrorBoundary';
import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
@@ -216,7 +217,9 @@ const PageLayout = () => {
position: 'relative',
}}
>
<ErrorBoundary>
<App />
</ErrorBoundary>
</Content>
{!shouldHideFooter && (
<Layout.Footer
@@ -95,6 +95,7 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
</Dropdown.Menu>
}
>
<span className='inline-flex'>
<Button
icon={currentButtonIcon}
aria-label={t('切换主题')}
@@ -102,6 +103,7 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
type='tertiary'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
/>
</span>
</Dropdown>
);
};
@@ -21,8 +21,9 @@ import React, { useRef, useEffect } from 'react';
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import ThinkingContent from './ThinkingContent';
import { Loader2, Check, X } from 'lucide-react';
import { Loader2, Check, X, Settings, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isAdmin } from '../../helpers/utils';
const MessageContent = ({
message,
@@ -64,6 +65,44 @@ const MessageContent = ({
errorText = t('请求发生错误');
}
if (message.errorCode === 'model_price_error') {
return (
<div className={`${className}`}>
<div
className='rounded-lg p-3 space-y-2'
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
}}
>
<div className='flex items-center gap-2'>
<AlertTriangle size={16} className='text-orange-500 shrink-0' />
<Typography.Text strong className='!text-[var(--semi-color-text-0)]'>
{t('模型价格未配置')}
</Typography.Text>
</div>
<Typography.Paragraph
className='!text-[var(--semi-color-text-1)] !text-sm !mb-0'
style={{ wordBreak: 'break-word' }}
>
{errorText}
</Typography.Paragraph>
{isAdmin() && (
<Button
size='small'
theme='light'
type='warning'
icon={<Settings size={14} />}
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
>
{t('前往设置')}
</Button>
)}
</div>
</div>
);
}
return (
<div className={`${className}`}>
<Typography.Text className='text-white'>{errorText}</Typography.Text>
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
import {
Input,
InputNumber,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import {
Hash,
@@ -241,15 +248,14 @@ const ParameterControl = ({
disabled={disabled}
/>
</div>
<Input
<InputNumber
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className='!rounded-lg'
onNumberChange={(value) => onInputChange('max_tokens', value)}
min={0}
precision={0}
style={{ width: '100%' }}
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
@@ -65,11 +65,15 @@ export const loadConfig = () => {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
max_tokens: Number.isNaN(parsedMaxTokens)
? parsedConfig?.inputs?.max_tokens
: parsedMaxTokens,
},
parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled,
+4 -9
View File
@@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
@@ -95,18 +94,14 @@ const RatioSetting = () => {
return (
<Spin spinning={loading} size='large'>
{/* 模型倍率设置以及价格编辑器 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='card' defaultActiveKey='visual'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
<Tabs type='card' defaultActiveKey='pricing'>
<Tabs.TabPane tab={t('模型定价设置')} itemKey='pricing'>
<ModelPricingCombined options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
@@ -91,6 +91,7 @@ const SystemSetting = () => {
EmailDomainRestrictionEnabled: '',
EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '',
SMTPForceAuthLogin: '',
EmailDomainWhitelist: [],
TelegramOAuthEnabled: '',
TelegramBotToken: '',
@@ -182,6 +183,7 @@ const SystemSetting = () => {
case 'EmailDomainRestrictionEnabled':
case 'EmailAliasRestrictionEnabled':
case 'SMTPSSLEnabled':
case 'SMTPForceAuthLogin':
case 'LinuxDOOAuthEnabled':
case 'discord.enabled':
case 'oidc.enabled':
@@ -1335,6 +1337,15 @@ const SystemSetting = () => {
>
{t('启用SMTP SSL')}
</Form.Checkbox>
<Form.Checkbox
field='SMTPForceAuthLogin'
noLabel
onChange={(e) =>
handleCheckboxChange('SMTPForceAuthLogin', e)
}
>
{t('强制使用 AUTH LOGIN')}
</Form.Checkbox>
</Col>
</Row>
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
@@ -208,6 +208,7 @@ const EditChannelModal = (props) => {
allow_safety_identifier: false,
allow_include_obfuscation: false,
allow_inference_geo: false,
allow_speed: false,
claude_beta_query: false,
upstream_model_update_check_enabled: false,
upstream_model_update_auto_sync_enabled: false,
@@ -890,6 +891,7 @@ const EditChannelModal = (props) => {
parsedSettings.allow_include_obfuscation || false;
data.allow_inference_geo =
parsedSettings.allow_inference_geo || false;
data.allow_speed = parsedSettings.allow_speed || false;
data.claude_beta_query = parsedSettings.claude_beta_query || false;
data.upstream_model_update_check_enabled =
parsedSettings.upstream_model_update_check_enabled === true;
@@ -919,6 +921,7 @@ const EditChannelModal = (props) => {
data.allow_safety_identifier = false;
data.allow_include_obfuscation = false;
data.allow_inference_geo = false;
data.allow_speed = false;
data.claude_beta_query = false;
data.upstream_model_update_check_enabled = false;
data.upstream_model_update_auto_sync_enabled = false;
@@ -936,6 +939,7 @@ const EditChannelModal = (props) => {
data.allow_safety_identifier = false;
data.allow_include_obfuscation = false;
data.allow_inference_geo = false;
data.allow_speed = false;
data.claude_beta_query = false;
data.upstream_model_update_check_enabled = false;
data.upstream_model_update_auto_sync_enabled = false;
@@ -1776,6 +1780,7 @@ const EditChannelModal = (props) => {
}
if (localInputs.type === 14) {
settings.allow_inference_geo = localInputs.allow_inference_geo === true;
settings.allow_speed = localInputs.allow_speed === true;
settings.claude_beta_query = localInputs.claude_beta_query === true;
}
}
@@ -1823,6 +1828,7 @@ const EditChannelModal = (props) => {
delete localInputs.allow_safety_identifier;
delete localInputs.allow_include_obfuscation;
delete localInputs.allow_inference_geo;
delete localInputs.allow_speed;
delete localInputs.claude_beta_query;
delete localInputs.upstream_model_update_check_enabled;
delete localInputs.upstream_model_update_auto_sync_enabled;
@@ -2480,6 +2486,7 @@ const EditChannelModal = (props) => {
</div>
<Form.Switch field='allow_service_tier' label={t('允许 service_tier 透传')} checkedText={t('开')} uncheckedText={t('关')} onChange={(value) => handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('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 数据驻留推理区域。默认关闭以避免未经授权透传地域信息')} />
<Form.Switch field='allow_speed' label={t('允许 speed 透传')} checkedText={t('开')} uncheckedText={t('关')} onChange={(value) => handleChannelOtherSettingsChange('allow_speed', value)} extraText={t('speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式')} />
</>
)}
</div>
@@ -30,6 +30,7 @@ import {
Banner,
} from '@douyinfe/semi-ui';
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
import { Settings } from 'lucide-react';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
@@ -168,6 +169,7 @@ const ModelTestModal = ({
}
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2'>
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
{testResult.success ? t('成功') : t('失败')}
@@ -181,6 +183,31 @@ const ModelTestModal = ({
</Typography.Text>
)}
</div>
{!testResult.success && testResult.message && (
<div className='flex flex-col gap-1'>
<Typography.Text
type='danger'
size='small'
className='break-all'
style={{ maxWidth: '400px', fontSize: '12px' }}
>
{testResult.message}
</Typography.Text>
{testResult.errorCode === 'model_price_error' && (
<Button
size='small'
theme='light'
type='warning'
icon={<Settings size={12} />}
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
style={{ width: 'fit-content' }}
>
{t('前往设置')}
</Button>
)}
</div>
)}
</div>
);
},
},
@@ -360,7 +360,7 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
{
title: t('索引'),
dataIndex: 'index',
render: (text) => `#${text}`,
render: (text) => `#${Number(text) + 1}`,
},
// {
// title: t(''),
@@ -26,7 +26,12 @@ import {
showSuccess,
renderQuota,
renderQuotaWithPrompt,
getCurrencyConfig,
} from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Button,
@@ -61,10 +66,12 @@ const EditRedemptionModal = (props) => {
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const getInitValues = () => ({
name: '',
quota: 100000,
amount: Number(quotaToDisplayAmount(100000).toFixed(6)),
count: 1,
expired_time: null,
redeem_type: REDEMPTION_REDEEM_TYPE.QUOTA,
@@ -107,6 +114,9 @@ const EditRedemptionModal = (props) => {
} else {
nextValues.expired_time = new Date(nextValues.expired_time * 1000);
}
nextValues.amount = Number(
quotaToDisplayAmount(nextValues.quota || 0).toFixed(6),
);
if (!nextValues.plan_id) {
nextValues.plan_id = undefined;
}
@@ -126,34 +136,55 @@ const EditRedemptionModal = (props) => {
}, [props.visiable]);
useEffect(() => {
if (formApiRef.current) {
if (!props.visiable || !formApiRef.current) {
return;
}
if (isEdit) {
loadRedemption();
} else {
formApiRef.current.setValues(getInitValues());
setLoading(false);
}
}
}, [props.editingRedemption.id]);
}, [props.visiable, props.editingRedemption.id, isEdit]);
const submit = async (values) => {
let name = values.name;
if (!name || name === '') {
if (values.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
const selectedPlan = subscriptionPlans.find(
(plan) => plan.id === Number(values.plan_id),
);
name = selectedPlan?.title || t('订阅套餐兑换码');
} else {
name = renderQuota(values.quota);
}
}
setLoading(true);
const localInputs = { ...values };
localInputs.count = parseInt(localInputs.count, 10) || 0;
localInputs.quota = parseInt(localInputs.quota, 10) || 0;
localInputs.plan_id = parseInt(localInputs.plan_id, 10) || 0;
localInputs.redeem_type =
localInputs.redeem_type || REDEMPTION_REDEEM_TYPE.QUOTA;
if (localInputs.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
if (localInputs.plan_id <= 0) {
showError(t('请选择订阅套餐'));
setLoading(false);
return;
}
localInputs.quota = 0;
} else {
localInputs.quota = displayAmountToQuota(Number(localInputs.amount || 0));
if (localInputs.quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
return;
}
localInputs.plan_id = 0;
}
let name = values.name;
if (!name || name === '') {
if (localInputs.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
const selectedPlan = subscriptionPlans.find(
(plan) => plan.id === localInputs.plan_id,
);
name = selectedPlan?.title || t('订阅套餐兑换码');
} else {
name = renderQuota(localInputs.quota);
}
}
localInputs.name = name;
if (!localInputs.expired_time) {
localInputs.expired_time = 0;
} else {
@@ -161,11 +192,6 @@ const EditRedemptionModal = (props) => {
localInputs.expired_time.getTime() / 1000,
);
}
if (localInputs.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
localInputs.quota = 0;
} else {
localInputs.plan_id = 0;
}
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, {
@@ -306,6 +332,7 @@ const EditRedemptionModal = (props) => {
showClear
/>
</Col>
{!isEdit && (
<Col span={12}>
<Form.Select
field='redeem_type'
@@ -324,7 +351,8 @@ const EditRedemptionModal = (props) => {
</Select.Option>
</Form.Select>
</Col>
<Col span={12}>
)}
<Col span={!isEdit ? 12 : 24}>
<Form.DatePicker
field='expired_time'
label={t('过期时间')}
@@ -334,29 +362,6 @@ const EditRedemptionModal = (props) => {
showClear
/>
</Col>
{redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION && (
<Col span={24}>
<Form.Select
field='plan_id'
label={t('订阅套餐')}
loading={plansLoading}
placeholder={t('请选择订阅套餐')}
style={{ width: '100%' }}
rules={[
{
required: true,
message: t('请选择订阅套餐'),
},
]}
>
{(subscriptionPlans || []).map((plan) => (
<Select.Option key={plan.id} value={plan.id}>
{plan.title}
</Select.Option>
))}
</Form.Select>
</Col>
)}
</Row>
</Card>
@@ -382,8 +387,40 @@ const EditRedemptionModal = (props) => {
</div>
<Row gutter={12}>
{redeemType === REDEMPTION_REDEEM_TYPE.QUOTA && (
<Col span={!isEdit ? 12 : 24}>
{redeemType === REDEMPTION_REDEEM_TYPE.QUOTA ? (
<Col span={24}>
<Form.InputNumber
field='amount'
label={t('金额')}
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
precision={6}
min={0}
step={0.000001}
style={{ width: '100%' }}
onChange={(val) => {
const amount = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('amount', amount);
formApiRef.current?.setValue(
'quota',
displayAmountToQuota(amount),
);
}}
showClear
/>
<div
className='text-xs cursor-pointer mt-1'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div
className='mt-2'
style={{ display: showQuotaInput ? 'block' : 'none' }}
>
<Form.AutoComplete
field='quota'
label={t('额度')}
@@ -412,8 +449,39 @@ const EditRedemptionModal = (props) => {
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
onChange={(val) => {
const quota = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('quota', quota);
formApiRef.current?.setValue(
'amount',
Number(quotaToDisplayAmount(quota).toFixed(6)),
);
}}
showClear
/>
</div>
</Col>
) : (
<Col span={24}>
<Form.Select
field='plan_id'
label={t('订阅套餐')}
loading={plansLoading}
placeholder={t('请选择订阅套餐')}
style={{ width: '100%' }}
rules={[
{
required: true,
message: t('请选择订阅套餐'),
},
]}
>
{(subscriptionPlans || []).map((plan) => (
<Select.Option key={plan.id} value={plan.id}>
{plan.title}
</Select.Option>
))}
</Form.Select>
</Col>
)}
{!isEdit && (
@@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => {
};
// Render group column
const renderGroupColumn = (text, record, t) => {
const renderGroupColumn = (text, record, t, groupRatios = {}) => {
if (text === 'auto') {
return (
<Tooltip
@@ -104,7 +104,17 @@ const renderGroupColumn = (text, record, t) => {
</Tooltip>
);
}
return renderGroup(text);
const ratio = groupRatios[text];
return (
<span className='flex items-center gap-1'>
{renderGroup(text)}
{ratio !== undefined && (
<Tag size='small' color='green' shape='circle'>
{ratio}x
</Tag>
)}
</span>
);
};
// Render token key column with show/hide and copy functionality
@@ -469,6 +479,7 @@ export const getTokensColumns = ({
setEditingToken,
setShowEdit,
refresh,
groupRatios = {},
}) => {
return [
{
@@ -490,7 +501,7 @@ export const getTokensColumns = ({
title: t('分组'),
dataIndex: 'group',
key: 'group',
render: (text, record) => renderGroupColumn(text, record, t),
render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
},
{
title: t('密钥'),
@@ -49,6 +49,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
t,
} = tokensData;
@@ -67,6 +68,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
});
}, [
t,
@@ -81,6 +83,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
]);
// Handle compact mode by removing fixed positioning
@@ -24,10 +24,14 @@ import {
showSuccess,
timestamp2string,
renderGroupOption,
renderQuotaWithPrompt,
getCurrencyConfig,
getModelCategories,
selectFilter,
} from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Button,
@@ -41,6 +45,7 @@ import {
Form,
Col,
Row,
InputNumber,
} from '@douyinfe/semi-ui';
import {
IconCreditCard,
@@ -62,11 +67,13 @@ const EditTokenModal = (props) => {
const formApiRef = useRef(null);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const isEdit = props.editingToken.id !== undefined;
const getInitValues = () => ({
name: '',
remain_quota: 0,
remain_amount: 0,
expired_time: -1,
unlimited_quota: true,
model_limits_enabled: false,
@@ -162,6 +169,9 @@ const EditTokenModal = (props) => {
} else {
data.model_limits = [];
}
data.remain_amount = Number(
quotaToDisplayAmount(data.remain_quota || 0).toFixed(6),
);
if (formApiRef.current) {
formApiRef.current.setValues({ ...getInitValues(), ...data });
}
@@ -209,7 +219,14 @@ const EditTokenModal = (props) => {
setLoading(true);
if (isEdit) {
let { tokenCount: _tc, ...localInputs } = values;
localInputs.remain_quota = parseInt(localInputs.remain_quota);
localInputs.remain_quota = localInputs.unlimited_quota
? 0
: displayAmountToQuota(localInputs.remain_amount);
if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
return;
}
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
if (isNaN(time)) {
@@ -245,7 +262,14 @@ const EditTokenModal = (props) => {
} else {
localInputs.name = baseName;
}
localInputs.remain_quota = parseInt(localInputs.remain_quota);
localInputs.remain_quota = localInputs.unlimited_quota
? 0
: displayAmountToQuota(localInputs.remain_amount);
if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
break;
}
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
@@ -366,6 +390,14 @@ const EditTokenModal = (props) => {
placeholder={t('令牌分组,默认为用户的分组')}
optionList={groups}
renderOptionItem={renderGroupOption}
filter={(input, option) => {
const q = input.toLowerCase();
return (
option.value?.toLowerCase().includes(q) ||
(typeof option.label === 'string' &&
option.label.toLowerCase().includes(q))
);
}}
showClear
style={{ width: '100%' }}
/>
@@ -489,27 +521,62 @@ const EditTokenModal = (props) => {
</div>
<Row gutter={12}>
<Col span={24}>
<Form.AutoComplete
<Form.InputNumber
field='remain_amount'
label={t('金额')}
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
precision={6}
disabled={values.unlimited_quota}
min={0}
step={0.000001}
onChange={(val) => {
const amount = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('remain_amount', amount);
formApiRef.current?.setValue(
'remain_quota',
displayAmountToQuota(amount),
);
}}
style={{ width: '100%' }}
showClear
/>
</Col>
<Col span={24}>
<div
className='text-xs cursor-pointer mt-1'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='remain_quota'
label={t('额度')}
placeholder={t('输入额度')}
type='number'
placeholder={t('输入额度')}
disabled={values.unlimited_quota}
extraText={renderQuotaWithPrompt(values.remain_quota)}
min={0}
step={500000}
rules={
values.unlimited_quota
? []
: [{ required: true, message: t('请输入额度') }]
}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
onChange={(val) => {
const quota = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('remain_quota', quota);
formApiRef.current?.setValue(
'remain_amount',
Number(quotaToDisplayAmount(quota).toFixed(6)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
</Col>
<Col span={24}>
<Form.Switch
@@ -24,7 +24,6 @@ import {
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt,
getCurrencyConfig,
} from '../../../../helpers';
import {
@@ -46,6 +45,8 @@ import {
Row,
Col,
InputNumber,
RadioGroup,
Radio,
} from '@douyinfe/semi-ui';
import {
IconUser,
@@ -53,7 +54,7 @@ import {
IconClose,
IconLink,
IconUserGroup,
IconPlus,
IconEdit,
} from '@douyinfe/semi-icons';
import UserBindingManagementModal from './UserBindingManagementModal';
@@ -63,13 +64,18 @@ const EditUserModal = (props) => {
const { t } = useTranslation();
const userId = props.editingUser.id;
const [loading, setLoading] = useState(true);
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
const [addQuotaLocal, setAddQuotaLocal] = useState('');
const [addAmountLocal, setAddAmountLocal] = useState('');
const [adjustModalOpen, setAdjustModalOpen] = useState(false);
const [adjustQuotaLocal, setAdjustQuotaLocal] = useState('');
const [adjustAmountLocal, setAdjustAmountLocal] = useState('');
const [adjustMode, setAdjustMode] = useState('add');
const [adjustLoading, setAdjustLoading] = useState(false);
const isMobile = useIsMobile();
const [groupOptions, setGroupOptions] = useState([]);
const [bindingModalVisible, setBindingModalVisible] = useState(false);
const formApiRef = useRef(null);
const [showAdjustQuotaRaw, setShowAdjustQuotaRaw] = useState(false);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const [inputs, setInputs] = useState(null);
const isEdit = Boolean(userId);
@@ -85,6 +91,7 @@ const EditUserModal = (props) => {
linux_do_id: '',
email: '',
quota: 0,
quota_amount: 0,
group: 'default',
remark: '',
});
@@ -107,13 +114,22 @@ const EditUserModal = (props) => {
const { success, message, data } = res.data;
if (success) {
data.password = '';
formApiRef.current?.setValues({ ...getInitValues(), ...data });
data.quota_amount = Number(
quotaToDisplayAmount(data.quota || 0).toFixed(6),
);
setInputs({ ...getInitValues(), ...data });
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
if (inputs && formApiRef.current) {
formApiRef.current.setValues(inputs);
}
}, [inputs]);
useEffect(() => {
loadUser();
if (userId) fetchGroups();
@@ -132,8 +148,8 @@ const EditUserModal = (props) => {
const submit = async (values) => {
setLoading(true);
let payload = { ...values };
if (typeof payload.quota === 'string')
payload.quota = parseInt(payload.quota) || 0;
delete payload.quota;
delete payload.quota_amount;
if (userId) {
payload.id = parseInt(userId);
}
@@ -150,11 +166,60 @@ const EditUserModal = (props) => {
setLoading(false);
};
/* --------------------- quota helper -------------------- */
const addLocalQuota = () => {
const current = parseInt(formApiRef.current?.getValue('quota') || 0);
const delta = parseInt(addQuotaLocal) || 0;
formApiRef.current?.setValue('quota', current + delta);
/* --------------------- atomic quota adjust -------------------- */
const adjustQuota = async () => {
const quotaVal = parseInt(adjustQuotaLocal) || 0;
if (quotaVal <= 0 && adjustMode !== 'override') return;
if (adjustMode === 'override' && (adjustQuotaLocal === '' || adjustQuotaLocal == null)) return;
setAdjustLoading(true);
try {
const res = await API.post('/api/user/manage', {
id: parseInt(userId),
action: 'add_quota',
mode: adjustMode,
value: adjustMode === 'override' ? quotaVal : Math.abs(quotaVal),
});
const { success, message } = res.data;
if (success) {
showSuccess(t('调整额度成功'));
setAdjustModalOpen(false);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
const userRes = await API.get(`/api/user/${userId}`);
if (userRes.data.success) {
const data = userRes.data.data;
data.password = '';
data.quota_amount = Number(
quotaToDisplayAmount(data.quota || 0).toFixed(6),
);
setInputs({ ...getInitValues(), ...data });
}
props.refresh();
} else {
showError(message);
}
} catch (e) {
showError(e.message);
}
setAdjustLoading(false);
};
const getPreviewText = () => {
const current = formApiRef.current?.getValue('quota') || 0;
const val = parseInt(adjustQuotaLocal) || 0;
let result;
switch (adjustMode) {
case 'add':
result = current + Math.abs(val);
return `${t('当前额度')}${renderQuota(current)}+${renderQuota(Math.abs(val))} = ${renderQuota(result)}`;
case 'subtract':
result = current - Math.abs(val);
return `${t('当前额度')}${renderQuota(current)}-${renderQuota(Math.abs(val))} = ${renderQuota(result)}`;
case 'override':
return `${t('当前额度')}${renderQuota(current)}${renderQuota(val)}`;
default:
return '';
}
};
/* --------------------------- UI --------------------------- */
@@ -305,24 +370,47 @@ const EditUserModal = (props) => {
<Col span={10}>
<Form.InputNumber
field='quota'
label={t('剩余额度')}
placeholder={t('请输入新的剩余额度')}
step={500000}
extraText={renderQuotaWithPrompt(values.quota || 0)}
rules={[{ required: true, message: t('请输入额度') }]}
field='quota_amount'
label={t('金额')}
prefix={getCurrencyConfig().symbol}
precision={6}
step={0.000001}
style={{ width: '100%' }}
readonly
/>
</Col>
<Col span={14}>
<Form.Slot label={t('添加额度')}>
<Form.Slot label={t('调整额度')}>
<Button
icon={<IconPlus />}
onClick={() => setIsModalOpen(true)}
/>
icon={<IconEdit />}
onClick={() => setAdjustModalOpen(true)}
>
{t('调整额度')}
</Button>
</Form.Slot>
</Col>
<Col span={24}>
<div
className='text-xs cursor-pointer'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='quota'
label={t('额度')}
placeholder={t('请输入额度')}
style={{ width: '100%' }}
readonly
/>
</div>
</Col>
</Row>
</Card>
)}
@@ -372,81 +460,102 @@ const EditUserModal = (props) => {
formApiRef={formApiRef}
/>
{/* 添加额度模态框 */}
{/* 调整额度模态框 */}
<Modal
centered
visible={addQuotaModalOpen}
onOk={() => {
addLocalQuota();
setIsModalOpen(false);
setAddQuotaLocal('');
setAddAmountLocal('');
}}
visible={adjustModalOpen}
onOk={adjustQuota}
onCancel={() => {
setIsModalOpen(false);
setAdjustModalOpen(false);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
setAdjustMode('add');
}}
confirmLoading={adjustLoading}
closable={null}
title={
<div className='flex items-center'>
<IconPlus className='mr-2' />
{t('添加额度')}
<IconEdit className='mr-2' />
{t('调整额度')}
</div>
}
>
<div className='mb-4'>
{(() => {
const current = formApiRef.current?.getValue('quota') || 0;
return (
<Text type='secondary' className='block mb-2'>
{`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
{getPreviewText()}
</Text>
);
})()}
</div>
{getCurrencyConfig().type !== 'TOKENS' && (
<div className='mb-3'>
<div className='mb-1'>
<Text size='small'>{t('操作')}</Text>
</div>
<RadioGroup
type='button'
value={adjustMode}
onChange={(e) => {
setAdjustMode(e.target.value);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
}}
style={{ width: '100%' }}
>
<Radio value='add'>{t('添加')}</Radio>
<Radio value='subtract'>{t('减少')}</Radio>
<Radio value='override'>{t('覆盖')}</Radio>
</RadioGroup>
</div>
<div className='mb-3'>
<div className='mb-1'>
<Text size='small'>{t('金额')}</Text>
<Text size='small' type='tertiary'>
{' '}
({t('仅用于换算,实际保存的是额度')})
</Text>
</div>
<InputNumber
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
value={addAmountLocal}
precision={2}
value={adjustAmountLocal}
precision={6}
min={adjustMode === 'override' ? undefined : 0}
step={0.000001}
onChange={(val) => {
setAddAmountLocal(val);
setAddQuotaLocal(
val != null && val !== ''
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
: '',
const amount = val === '' || val == null ? '' : val;
setAdjustAmountLocal(amount);
setAdjustQuotaLocal(
amount === ''
? ''
: adjustMode === 'override'
? displayAmountToQuota(amount)
: displayAmountToQuota(Math.abs(amount)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
)}
<div>
<div
className='text-xs cursor-pointer mt-2'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowAdjustQuotaRaw((v) => !v)}
>
{showAdjustQuotaRaw
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showAdjustQuotaRaw ? 'block' : 'none' }} className='mt-2'>
<div className='mb-1'>
<Text size='small'>{t('额度')}</Text>
</div>
<InputNumber
placeholder={t('输入额度')}
value={addQuotaLocal}
value={adjustQuotaLocal}
min={adjustMode === 'override' ? undefined : 0}
onChange={(val) => {
setAddQuotaLocal(val);
setAddAmountLocal(
val != null && val !== ''
? Number(
(
quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
).toFixed(2),
)
: '',
const quota = val === '' || val == null ? '' : val;
setAdjustQuotaLocal(quota);
setAdjustAmountLocal(
quota === ''
? ''
: adjustMode === 'override'
? Number(quotaToDisplayAmount(quota).toFixed(6))
: Number(quotaToDisplayAmount(Math.abs(quota)).toFixed(6)),
);
}}
style={{ width: '100%' }}

Some files were not shown because too many files have changed in this diff Show More