Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59c582d13c | |||
| 2819e3a1d1 | |||
| ed7f839911 | |||
| 040e8c1da8 | |||
| 0664bb3f65 | |||
| c7cf20391e | |||
| b07f0b9626 | |||
| 53cf37a469 | |||
| 3bda738ec1 | |||
| 160cb28572 | |||
| 274307b0a9 | |||
| a19a63b98c | |||
| 78e4cb3cad | |||
| c734db34e8 | |||
| a18ea3cc16 | |||
| aafbd78887 | |||
| 77897a8101 | |||
| 9b4ffb0875 | |||
| 606a4eee96 | |||
| 9ffb85a36b | |||
| c3b8fa29b2 | |||
| a057eddac1 | |||
| 1110403750 | |||
| 3a2aecbc01 | |||
| 49648d8b80 | |||
| 59d5aef393 | |||
| 48695e0e6f | |||
| e96ca77542 | |||
| 1ad2557668 | |||
| ded3bb9cb1 | |||
| dc83c4af31 | |||
| cf1b485389 | |||
| 741aaf4436 | |||
| c66636a0c7 | |||
| f7cdc727df | |||
| 07843d7898 | |||
| 559c98f261 | |||
| 960bf9c49e | |||
| 12a48c620e | |||
| eacc245bad | |||
| 03758a4a85 | |||
| 8fc0eb78e2 | |||
| 427fb7eaf6 | |||
| 4cd0e3651d | |||
| 677d02f2ab | |||
| c583382af7 | |||
| b1950655e7 | |||
| 22cbbcab4c | |||
| 873067f7d1 | |||
| 82c2008d2c | |||
| 495e4f5e17 | |||
| 23fde25b15 | |||
| ac90d9f185 | |||
| bb5b9eaca2 | |||
| b713e277cd | |||
| 08a5243bbc | |||
| c9611c493f | |||
| 314ae40820 | |||
| 116e0b8f1c | |||
| 70560d5371 |
@@ -19,6 +19,8 @@
|
||||
# HOSTNAME=your-hostname
|
||||
|
||||
# 数据库相关配置
|
||||
# 启用错误日志记录
|
||||
# ERROR_LOG_ENABLED=true
|
||||
# 数据库连接字符串
|
||||
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
|
||||
# 日志数据库连接字符串
|
||||
|
||||
@@ -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
|
||||
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
|
||||
@@ -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
|
||||
@@ -29,3 +29,5 @@ data/
|
||||
.gomodcache/
|
||||
.gocache-temp
|
||||
.gopath
|
||||
|
||||
token_estimator_test.go
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -769,9 +774,10 @@ func TestChannel(c *gin.Context) {
|
||||
consumedTime := float64(milliseconds) / 1000.0
|
||||
if result.newAPIError != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": result.newAPIError.Error(),
|
||||
"time": consumedTime,
|
||||
"success": false,
|
||||
"message": result.newAPIError.Error(),
|
||||
"time": consumedTime,
|
||||
"error_code": result.newAPIError.GetErrorCode(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+52
-7
@@ -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,47 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleCommonUser
|
||||
case "add_quota":
|
||||
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", 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", 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", 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 {
|
||||
|
||||
@@ -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"`
|
||||
// TODO:ensure that the logic remains correct after the stream is started.
|
||||
//Stream json.RawMessage `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
|
||||
+24
-30
@@ -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"`
|
||||
@@ -223,14 +237,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,17 +264,11 @@ 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 != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createClaudeFileSource(data),
|
||||
})
|
||||
}
|
||||
if source := media.ToFileSource(); source != nil {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,17 +293,11 @@ 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 != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createClaudeFileSource(data),
|
||||
})
|
||||
}
|
||||
if source := media.ToFileSource(); source != nil {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
case "tool_use":
|
||||
if media.Name != "" {
|
||||
|
||||
+13
-11
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,6 +111,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
|
||||
if c.Query("alt") == "sse" {
|
||||
return true
|
||||
}
|
||||
// Native Gemini API uses URL action to indicate streaming:
|
||||
// /v1beta/models/{model}:streamGenerateContent
|
||||
if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -210,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
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGeminiChatRequest_IsStream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
query string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "streamGenerateContent without alt=sse",
|
||||
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
|
||||
query: "key=sk-xxx",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "streamGenerateContent with alt=sse",
|
||||
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
|
||||
query: "alt=sse&key=sk-xxx",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "generateContent without alt=sse",
|
||||
path: "/v1beta/models/gemini-2.0-flash:generateContent",
|
||||
query: "key=sk-xxx",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "generateContent with alt=sse",
|
||||
path: "/v1beta/models/gemini-2.0-flash:generateContent",
|
||||
query: "alt=sse",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GenerateContent capitalized",
|
||||
path: "/v1beta/models/gemini-2.0-flash:GenerateContent",
|
||||
query: "key=sk-xxx",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "embedding path",
|
||||
path: "/v1beta/models/gemini-2.0-flash:embedContent",
|
||||
query: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
url := tt.path
|
||||
if tt.query != "" {
|
||||
url += "?" + tt.query
|
||||
}
|
||||
c.Request, _ = http.NewRequest("POST", url, nil)
|
||||
|
||||
req := &GeminiChatRequest{}
|
||||
assert.Equal(t, tt.expected, req.IsStream(c))
|
||||
})
|
||||
}
|
||||
}
|
||||
+53
-47
@@ -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
|
||||
}
|
||||
case ContentTypeInputAudio:
|
||||
meta.FileType = types.FileTypeAudio
|
||||
case ContentTypeFile:
|
||||
meta.FileType = types.FileTypeFile
|
||||
case ContentTypeVideoUrl:
|
||||
meta.FileType = types.FileTypeVideo
|
||||
}
|
||||
} 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,
|
||||
})
|
||||
}
|
||||
} 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 {
|
||||
|
||||
+10
-10
@@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "35.7.5",
|
||||
"electron": "39.8.5",
|
||||
"electron-builder": "^26.7.0"
|
||||
}
|
||||
},
|
||||
@@ -777,9 +777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2145,9 +2145,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "35.7.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
|
||||
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
|
||||
"version": "39.8.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
|
||||
"integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -3279,9 +3279,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
Vendored
+1
-1
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "35.7.5",
|
||||
"electron": "39.8.5",
|
||||
"electron-builder": "^26.7.0"
|
||||
},
|
||||
"build": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: "額度不能為負數!"
|
||||
|
||||
+57
-20
@@ -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 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的令牌",
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -11,9 +11,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrRedeemFailed is returned when redemption fails due to database error
|
||||
var ErrRedeemFailed = errors.New("redeem.failed")
|
||||
|
||||
type Redemption struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
|
||||
+17
-18
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
|
||||
|
||||
// TwoFA 用户2FA设置表
|
||||
type TwoFA struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
|
||||
@@ -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("aDatas).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
@@ -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, fmt.Errorf("%w: %v", ErrDatabase, err)
|
||||
}
|
||||
return nil
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
|
||||
require.EqualValues(t, 50, usage.CacheCreationInputTokens)
|
||||
require.EqualValues(t, 53, usage.OutputTokens)
|
||||
require.NotNil(t, usage.CacheCreation)
|
||||
require.EqualValues(t, 10, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 30, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
|
||||
})
|
||||
|
||||
@@ -108,4 +108,22 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
|
||||
require.EqualValues(t, 7, usage.CacheReadInputTokens)
|
||||
require.EqualValues(t, 6, usage.CacheCreationInputTokens)
|
||||
})
|
||||
|
||||
t.Run("default aggregate cache creation to 5m when split missing", func(t *testing.T) {
|
||||
claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
|
||||
OutputTokens: 53,
|
||||
CacheCreationInputTokens: 50,
|
||||
}}
|
||||
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedCreationTokens: 50,
|
||||
},
|
||||
}}
|
||||
|
||||
usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, usage.CacheCreation)
|
||||
require.EqualValues(t, 50, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 0, usage.CacheCreation.Ephemeral1hInputTokens)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
@@ -234,7 +177,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
}
|
||||
// TODO: 临时处理
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
claudeRequest.TopP = common.GetPointer[float64](0)
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
|
||||
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
@@ -406,44 +349,33 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
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)
|
||||
@@ -648,6 +580,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
||||
return dto.Usage{}
|
||||
}
|
||||
clone := *usage
|
||||
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
|
||||
usage.PromptTokensDetails.CachedCreationTokens,
|
||||
usage.ClaudeCacheCreation5mTokens,
|
||||
usage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
||||
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
||||
clone.PromptTokens = totalInputTokens
|
||||
@@ -677,11 +614,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
|
||||
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
|
||||
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
|
||||
}
|
||||
if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens,
|
||||
Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens,
|
||||
}
|
||||
cacheCreation5m := 0
|
||||
cacheCreation1h := 0
|
||||
if usage.CacheCreation != nil {
|
||||
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
|
||||
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
|
||||
} else {
|
||||
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
|
||||
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
|
||||
usage.CacheCreationInputTokens,
|
||||
cacheCreation5m,
|
||||
cacheCreation1h,
|
||||
)
|
||||
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
|
||||
}
|
||||
if usage.CacheCreation != nil {
|
||||
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
|
||||
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
|
||||
}
|
||||
return usage
|
||||
}
|
||||
@@ -857,7 +809,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"
|
||||
|
||||
@@ -258,6 +258,23 @@ func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(t *testing.T) {
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 20,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 30,
|
||||
CachedCreationTokens: 50,
|
||||
},
|
||||
UsageSemantic: "anthropic",
|
||||
}
|
||||
|
||||
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
|
||||
|
||||
require.Equal(t, 50, openAIUsage.ClaudeCacheCreation5mTokens)
|
||||
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-3-5-sonnet",
|
||||
|
||||
@@ -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, "")
|
||||
} else {
|
||||
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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -369,7 +369,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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -690,6 +691,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 +701,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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+47
-17
@@ -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 !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
+37
-15
@@ -227,21 +227,31 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
||||
if oaiUsage == nil {
|
||||
return nil
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
|
||||
oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
usage := &dto.ClaudeUsage{
|
||||
InputTokens: oaiUsage.PromptTokens,
|
||||
OutputTokens: oaiUsage.CompletionTokens,
|
||||
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||
}
|
||||
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
|
||||
if cacheCreation5m > 0 || cacheCreation1h > 0 {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
Ephemeral5mInputTokens: cacheCreation5m,
|
||||
Ephemeral1hInputTokens: cacheCreation1h,
|
||||
}
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
|
||||
return tokens5m + remainder, tokens1h
|
||||
}
|
||||
|
||||
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
|
||||
if info.ClaudeConvertInfo.Done {
|
||||
return nil
|
||||
@@ -426,23 +436,28 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
}
|
||||
|
||||
if len(openAIResponse.Choices) == 0 {
|
||||
// no choices
|
||||
// 可能为非标准的 OpenAI 响应,判断是否已经完成
|
||||
if info.ClaudeConvertInfo.Done {
|
||||
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
}
|
||||
if oaiUsage != nil {
|
||||
stopOpenBlocks()
|
||||
oaiUsage := info.ClaudeConvertInfo.Usage
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
},
|
||||
})
|
||||
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
|
||||
if stopReason == "" {
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReason),
|
||||
},
|
||||
})
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
info.ClaudeConvertInfo.Done = true
|
||||
}
|
||||
return claudeResponses
|
||||
} else {
|
||||
@@ -450,6 +465,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
|
||||
if doneChunk {
|
||||
info.FinishReason = *chosenChoice.FinishReason
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
|
||||
// Defer closing until usage is available so the final message_delta carries it.
|
||||
return claudeResponses
|
||||
}
|
||||
}
|
||||
|
||||
var claudeResponse dto.ClaudeResponse
|
||||
|
||||
+53
-32
@@ -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 缓存
|
||||
var contextKey string
|
||||
if source.IsURL() && c != nil {
|
||||
contextKey = getContextCacheKey(source.URL)
|
||||
if cachedData, exists := c.Get(contextKey); exists {
|
||||
data := cachedData.(*types.CachedFileData)
|
||||
source.SetCache(data)
|
||||
registerSourceForCleanup(c, source)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行加载逻辑
|
||||
// 4. 根据来源类型加载(含 URL context 缓存查找)
|
||||
var cachedData *types.CachedFileData
|
||||
var contextKey string
|
||||
var err error
|
||||
|
||||
if source.IsURL() {
|
||||
cachedData, err = loadFromURL(c, source.URL, reason...)
|
||||
} else {
|
||||
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -361,6 +361,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 +376,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
|
||||
if printErr {
|
||||
common.SysError("model price not found: " + name)
|
||||
}
|
||||
return price, true
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func UpdateModelRatioByJSONString(jsonStr string) error {
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,12 @@ type TokenCountMeta struct {
|
||||
|
||||
type FileMeta struct {
|
||||
FileType
|
||||
MimeType string
|
||||
Source *FileSource // 统一的文件来源(URL 或 base64)
|
||||
Detail string // 图片细节级别(low/high/auto)
|
||||
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,
|
||||
|
||||
Vendored
+5
-4
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "react-template",
|
||||
@@ -10,7 +11,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.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
@@ -776,7 +777,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.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
|
||||
|
||||
"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 +1105,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=="],
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 提取body内容,如果没有body标签则使用全部内容
|
||||
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);
|
||||
@@ -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'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
>
|
||||
{api.url}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</Content>
|
||||
{!shouldHideFooter && (
|
||||
<Layout.Footer
|
||||
|
||||
@@ -95,13 +95,15 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={currentButtonIcon}
|
||||
aria-label={t('切换主题')}
|
||||
theme='borderless'
|
||||
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 className='inline-flex'>
|
||||
<Button
|
||||
icon={currentButtonIcon}
|
||||
aria-label={t('切换主题')}
|
||||
theme='borderless'
|
||||
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,17 +169,43 @@ const ModelTestModal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
|
||||
{testResult.success ? t('成功') : t('失败')}
|
||||
</Tag>
|
||||
{testResult.success && (
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请求时长: ${time}s').replace(
|
||||
'${time}',
|
||||
testResult.time.toFixed(2),
|
||||
<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('失败')}
|
||||
</Tag>
|
||||
{testResult.success && (
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请求时长: ${time}s').replace(
|
||||
'${time}',
|
||||
testResult.time.toFixed(2),
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,8 +25,12 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
getCurrencyConfig,
|
||||
} from '../../../../helpers';
|
||||
import {
|
||||
quotaToDisplayAmount,
|
||||
displayAmountToQuota,
|
||||
} from '../../../../helpers/quota';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Button,
|
||||
@@ -41,6 +45,7 @@ import {
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
InputNumber,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCreditCard,
|
||||
@@ -57,10 +62,12 @@ const EditRedemptionModal = (props) => {
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
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,
|
||||
});
|
||||
@@ -79,6 +86,7 @@ const EditRedemptionModal = (props) => {
|
||||
} else {
|
||||
data.expired_time = new Date(data.expired_time * 1000);
|
||||
}
|
||||
data.amount = Number(quotaToDisplayAmount(data.quota || 0).toFixed(6));
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
@@ -104,7 +112,12 @@ const EditRedemptionModal = (props) => {
|
||||
setLoading(true);
|
||||
let localInputs = { ...values };
|
||||
localInputs.count = parseInt(localInputs.count) || 0;
|
||||
localInputs.quota = parseInt(localInputs.quota) || 0;
|
||||
localInputs.quota = displayAmountToQuota(localInputs.amount);
|
||||
if (localInputs.quota <= 0) {
|
||||
showError(t('请输入金额'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
localInputs.name = name;
|
||||
if (!localInputs.expired_time) {
|
||||
localInputs.expired_time = 0;
|
||||
@@ -285,37 +298,63 @@ const EditRedemptionModal = (props) => {
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.AutoComplete
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
<Col span={24}>
|
||||
<Form.InputNumber
|
||||
field='amount'
|
||||
label={t('金额')}
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
placeholder={t('输入金额')}
|
||||
precision={6}
|
||||
min={0}
|
||||
step={0.000001}
|
||||
style={{ width: '100%' }}
|
||||
type='number'
|
||||
rules={[
|
||||
{ required: true, message: t('请输入额度') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('额度必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
extraText={renderQuotaWithPrompt(
|
||||
Number(values.quota) || 0,
|
||||
)}
|
||||
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 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 style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
|
||||
<Form.InputNumber
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('输入额度')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入额度') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('额度必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
onChange={(val) => {
|
||||
const quota = val === '' || val == null ? 0 : val;
|
||||
formApiRef.current?.setValue('quota', quota);
|
||||
formApiRef.current?.setValue(
|
||||
'amount',
|
||||
Number(quotaToDisplayAmount(quota).toFixed(6)),
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
{!isEdit && (
|
||||
<Col span={12}>
|
||||
|
||||
@@ -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,28 +521,63 @@ const EditTokenModal = (props) => {
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.AutoComplete
|
||||
field='remain_quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
type='number'
|
||||
<Form.InputNumber
|
||||
field='remain_amount'
|
||||
label={t('金额')}
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
placeholder={t('输入金额')}
|
||||
precision={6}
|
||||
disabled={values.unlimited_quota}
|
||||
extraText={renderQuotaWithPrompt(values.remain_quota)}
|
||||
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$' },
|
||||
]}
|
||||
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('输入额度')}
|
||||
disabled={values.unlimited_quota}
|
||||
min={0}
|
||||
step={500000}
|
||||
rules={
|
||||
values.unlimited_quota
|
||||
? []
|
||||
: [{ required: true, message: t('请输入额度') }]
|
||||
}
|
||||
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
|
||||
field='unlimited_quota'
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
renderModelPriceSimple,
|
||||
} from '../../../helpers';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { Route, Sparkles } from 'lucide-react';
|
||||
import { CircleAlert, Route, Sparkles } from 'lucide-react';
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
@@ -141,12 +141,58 @@ function renderType(type, t) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderIsStream(bool, t) {
|
||||
function buildStreamStatusTooltip(ss, t) {
|
||||
if (!ss) return null;
|
||||
const lines = [
|
||||
t('流状态') + ':' + t('异常'),
|
||||
(ss.end_reason || 'unknown'),
|
||||
];
|
||||
if (ss.error_count > 0) {
|
||||
lines.push(`${t('软错误')}: ${ss.error_count}`);
|
||||
}
|
||||
if (ss.end_error) {
|
||||
lines.push(ss.end_error);
|
||||
}
|
||||
return (
|
||||
<div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIsStream(bool, t, streamStatus) {
|
||||
const isError = streamStatus && streamStatus.status !== 'ok';
|
||||
|
||||
if (bool) {
|
||||
return (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('流')}
|
||||
</Tag>
|
||||
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('流')}
|
||||
</Tag>
|
||||
{isError && (
|
||||
<Tooltip content={buildStreamStatusTooltip(streamStatus, t)}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
top: -4,
|
||||
lineHeight: 1,
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<CircleAlert
|
||||
size={14}
|
||||
strokeWidth={2.5}
|
||||
color='currentColor'
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@@ -694,7 +740,7 @@ export const getLogsColumns = ({
|
||||
<Space>
|
||||
{renderUseTime(text, t)}
|
||||
{renderFirstUseTime(other?.frt, t)}
|
||||
{renderIsStream(record.is_stream, t)}
|
||||
{renderIsStream(record.is_stream, t, other?.stream_status)}
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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))}`}
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
<Text type='secondary' className='block mb-2'>
|
||||
{getPreviewText()}
|
||||
</Text>
|
||||
</div>
|
||||
{getCurrencyConfig().type !== 'TOKENS' && (
|
||||
<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}
|
||||
onChange={(val) => {
|
||||
setAddAmountLocal(val);
|
||||
setAddQuotaLocal(
|
||||
val != null && val !== ''
|
||||
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
<div className='mb-3'>
|
||||
<div className='mb-1'>
|
||||
<Text size='small'>{t('操作')}</Text>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
<InputNumber
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
placeholder={t('输入金额')}
|
||||
value={adjustAmountLocal}
|
||||
precision={6}
|
||||
min={adjustMode === 'override' ? undefined : 0}
|
||||
step={0.000001}
|
||||
onChange={(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
|
||||
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%' }}
|
||||
|
||||
Vendored
+12
-1
@@ -150,7 +150,18 @@ export const buildApiPayload = (
|
||||
const value = inputs[param];
|
||||
const hasValue = value !== undefined && value !== null;
|
||||
|
||||
if (enabled && hasValue) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (param === 'max_tokens') {
|
||||
if (typeof value === 'number') {
|
||||
payload[param] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasValue) {
|
||||
payload[param] = value;
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+55
@@ -387,3 +387,58 @@ export const generateChartTimePoints = (
|
||||
|
||||
return chartTimePoints;
|
||||
};
|
||||
|
||||
// ========== 用户维度数据处理 ==========
|
||||
export const processUserData = (data, dataExportDefaultTime, limit = 10) => {
|
||||
const userQuotaTotal = new Map();
|
||||
data.forEach((item) => {
|
||||
const prev = userQuotaTotal.get(item.username) || 0;
|
||||
userQuotaTotal.set(item.username, prev + item.quota);
|
||||
});
|
||||
|
||||
const sorted = Array.from(userQuotaTotal.entries()).sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
);
|
||||
const topUsers = sorted.slice(0, limit).map(([u]) => u);
|
||||
const topUserSet = new Set(topUsers);
|
||||
|
||||
const rankingData = sorted.slice(0, limit).map(([username, quota]) => ({
|
||||
User: username,
|
||||
Quota: quota,
|
||||
}));
|
||||
|
||||
const showYear = isDataCrossYear(data.map((item) => item.created_at));
|
||||
|
||||
const timeUserMap = new Map();
|
||||
const allTimePoints = new Set();
|
||||
|
||||
data.forEach((item) => {
|
||||
const timeKey = timestamp2string1(
|
||||
item.created_at,
|
||||
dataExportDefaultTime,
|
||||
showYear,
|
||||
);
|
||||
allTimePoints.add(timeKey);
|
||||
const user = topUserSet.has(item.username) ? item.username : null;
|
||||
if (!user) return;
|
||||
const key = `${timeKey}-${user}`;
|
||||
const prev = timeUserMap.get(key) || { quota: 0 };
|
||||
timeUserMap.set(key, { quota: prev.quota + item.quota });
|
||||
});
|
||||
|
||||
const sortedTimePoints = Array.from(allTimePoints).sort();
|
||||
const trendData = [];
|
||||
sortedTimePoints.forEach((time) => {
|
||||
topUsers.forEach((user) => {
|
||||
const key = `${time}-${user}`;
|
||||
const val = timeUserMap.get(key);
|
||||
trendData.push({
|
||||
Time: time,
|
||||
User: user,
|
||||
Quota: val?.quota || 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { rankingData, trendData, topUsers };
|
||||
};
|
||||
|
||||
Vendored
+29
-7
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { getCurrencyConfig } from './render';
|
||||
|
||||
export const getQuotaPerUnit = () => {
|
||||
@@ -7,19 +25,23 @@ export const getQuotaPerUnit = () => {
|
||||
|
||||
export const quotaToDisplayAmount = (quota) => {
|
||||
const q = Number(quota || 0);
|
||||
if (!Number.isFinite(q) || q <= 0) return 0;
|
||||
if (!Number.isFinite(q) || q === 0) return 0;
|
||||
const sign = Math.sign(q);
|
||||
const abs = Math.abs(q);
|
||||
const { type, rate } = getCurrencyConfig();
|
||||
if (type === 'TOKENS') return q;
|
||||
const usd = q / getQuotaPerUnit();
|
||||
if (type === 'USD') return usd;
|
||||
return usd * (rate || 1);
|
||||
const usd = abs / getQuotaPerUnit();
|
||||
if (type === 'USD') return sign * usd;
|
||||
return sign * usd * (rate || 1);
|
||||
};
|
||||
|
||||
export const displayAmountToQuota = (amount) => {
|
||||
const val = Number(amount || 0);
|
||||
if (!Number.isFinite(val) || val <= 0) return 0;
|
||||
if (!Number.isFinite(val) || val === 0) return 0;
|
||||
const sign = Math.sign(val);
|
||||
const abs = Math.abs(val);
|
||||
const { type, rate } = getCurrencyConfig();
|
||||
if (type === 'TOKENS') return Math.round(val);
|
||||
const usd = type === 'USD' ? val : val / (rate || 1);
|
||||
return Math.round(usd * getQuotaPerUnit());
|
||||
const usd = type === 'USD' ? abs : abs / (rate || 1);
|
||||
return sign * Math.round(usd * getQuotaPerUnit());
|
||||
};
|
||||
|
||||
Vendored
+12
@@ -1620,6 +1620,18 @@ function renderPriceSimpleCore({
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderTaskBillingProcess(other, content) {
|
||||
if (other?.task_id != null) {
|
||||
return renderBillingArticle(
|
||||
[content].filter(Boolean),
|
||||
{ showReferenceNote: false },
|
||||
);
|
||||
}
|
||||
return renderBillingArticle([
|
||||
buildBillingText('任务预扣费(将在任务完成后按实际token重算)'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderModelPrice(
|
||||
inputTokens,
|
||||
completionTokens,
|
||||
|
||||
Vendored
+14
@@ -33,6 +33,20 @@ export async function fetchTokenKey(tokenId) {
|
||||
return data.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个令牌的真实 key
|
||||
* @param {number[]} tokenIds
|
||||
* @returns {Promise<Record<number, string>>} 返回 {id: key} map,key 不带 sk- 前缀
|
||||
*/
|
||||
export async function fetchTokenKeysBatch(tokenIds) {
|
||||
const response = await API.post('/api/token/batch/keys', { ids: tokenIds });
|
||||
const { success, data, message } = response.data || {};
|
||||
if (!success || !data?.keys) {
|
||||
throw new Error(message || 'Failed to fetch token keys');
|
||||
}
|
||||
return data.keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的 token keys
|
||||
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
|
||||
|
||||
+5
-3
@@ -890,7 +890,7 @@ export const useChannelsData = () => {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const { success, message, time } = res.data;
|
||||
const { success, message, time, error_code } = res.data;
|
||||
|
||||
// 更新测试结果
|
||||
setModelTestResults((prev) => ({
|
||||
@@ -900,6 +900,7 @@ export const useChannelsData = () => {
|
||||
message,
|
||||
time: time || 0,
|
||||
timestamp: Date.now(),
|
||||
errorCode: error_code || null,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -927,7 +928,7 @@ export const useChannelsData = () => {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError(`${t('模型')} ${model}: ${message}`);
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
// 处理网络错误
|
||||
@@ -939,9 +940,10 @@ export const useChannelsData = () => {
|
||||
message: error.message || t('网络错误'),
|
||||
time: 0,
|
||||
timestamp: Date.now(),
|
||||
errorCode: null,
|
||||
},
|
||||
}));
|
||||
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
||||
showError(error.message || t('测试失败'));
|
||||
} finally {
|
||||
// 从正在测试的模型集合中移除
|
||||
setTestingModels((prev) => {
|
||||
|
||||
+131
-6
@@ -34,8 +34,14 @@ import {
|
||||
updateChartSpec,
|
||||
updateMapValue,
|
||||
initializeMaps,
|
||||
processUserData,
|
||||
} from '../../helpers/dashboard';
|
||||
|
||||
const USER_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||||
];
|
||||
|
||||
export const useDashboardCharts = (
|
||||
dataExportDefaultTime,
|
||||
setTrendData,
|
||||
@@ -179,7 +185,6 @@ export const useDashboardCharts = (
|
||||
},
|
||||
});
|
||||
|
||||
// 模型消耗趋势折线图
|
||||
const [spec_model_line, setSpecModelLine] = useState({
|
||||
type: 'line',
|
||||
data: [
|
||||
@@ -197,7 +202,7 @@ export const useDashboardCharts = (
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('模型消耗趋势'),
|
||||
text: t('调用趋势'),
|
||||
subtext: '',
|
||||
},
|
||||
tooltip: {
|
||||
@@ -215,7 +220,6 @@ export const useDashboardCharts = (
|
||||
},
|
||||
});
|
||||
|
||||
// 模型调用次数排行柱状图
|
||||
const [spec_rank_bar, setSpecRankBar] = useState({
|
||||
type: 'bar',
|
||||
data: [
|
||||
@@ -259,6 +263,82 @@ export const useDashboardCharts = (
|
||||
},
|
||||
});
|
||||
|
||||
// ========== Admin: 用户消耗排行 ==========
|
||||
const [spec_user_rank, setSpecUserRank] = useState({
|
||||
type: 'bar',
|
||||
data: [{ id: 'userRankData', values: [] }],
|
||||
xField: 'rawQuota',
|
||||
yField: 'User',
|
||||
seriesField: 'User',
|
||||
direction: 'horizontal',
|
||||
legends: { visible: false },
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('用户消耗排行'),
|
||||
subtext: '',
|
||||
},
|
||||
bar: {
|
||||
state: { hover: { stroke: '#000', lineWidth: 1 } },
|
||||
},
|
||||
label: {
|
||||
visible: true,
|
||||
position: 'outside',
|
||||
formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2),
|
||||
},
|
||||
axes: [{
|
||||
orient: 'left',
|
||||
type: 'band',
|
||||
label: { visible: true },
|
||||
}, {
|
||||
orient: 'bottom',
|
||||
type: 'linear',
|
||||
visible: false,
|
||||
}],
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||
}],
|
||||
},
|
||||
},
|
||||
color: { type: 'ordinal', range: USER_COLORS },
|
||||
});
|
||||
|
||||
// ========== Admin: 用户消耗趋势 ==========
|
||||
const [spec_user_trend, setSpecUserTrend] = useState({
|
||||
type: 'area',
|
||||
data: [{ id: 'userTrendData', values: [] }],
|
||||
xField: 'Time',
|
||||
yField: 'rawQuota',
|
||||
seriesField: 'User',
|
||||
stack: false,
|
||||
legends: { visible: true, selectMode: 'single' },
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('用户消耗趋势'),
|
||||
subtext: '',
|
||||
},
|
||||
axes: [{
|
||||
orient: 'left',
|
||||
label: {
|
||||
formatMethod: (value) => renderQuota(value, 2),
|
||||
},
|
||||
}],
|
||||
area: { style: { fillOpacity: 0.15 } },
|
||||
line: { style: { lineWidth: 2 } },
|
||||
point: { visible: false },
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||
}],
|
||||
},
|
||||
},
|
||||
color: { type: 'ordinal', range: USER_COLORS },
|
||||
});
|
||||
|
||||
// ========== 数据处理函数 ==========
|
||||
const generateModelColors = useCallback((uniqueModels, modelColors) => {
|
||||
const newModelColors = {};
|
||||
@@ -426,6 +506,51 @@ export const useDashboardCharts = (
|
||||
],
|
||||
);
|
||||
|
||||
// ========== 用户维度图表数据处理 ==========
|
||||
const updateUserChartData = useCallback(
|
||||
(data) => {
|
||||
const { rankingData, trendData: userTrend } = processUserData(
|
||||
data,
|
||||
dataExportDefaultTime,
|
||||
10,
|
||||
);
|
||||
|
||||
const userRankValues = rankingData.map((item) => ({
|
||||
User: item.User,
|
||||
rawQuota: item.Quota,
|
||||
Quota: getQuotaWithUnit(item.Quota, 4),
|
||||
})).sort((a, b) => b.rawQuota - a.rawQuota);
|
||||
|
||||
const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
|
||||
|
||||
setSpecUserRank((prev) => ({
|
||||
...prev,
|
||||
data: [{ id: 'userRankData', values: userRankValues }],
|
||||
title: {
|
||||
...prev.title,
|
||||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const userTrendValues = userTrend.map((item) => ({
|
||||
Time: item.Time,
|
||||
User: item.User,
|
||||
rawQuota: item.Quota,
|
||||
Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
|
||||
}));
|
||||
|
||||
setSpecUserTrend((prev) => ({
|
||||
...prev,
|
||||
data: [{ id: 'userTrendData', values: userTrendValues }],
|
||||
title: {
|
||||
...prev.title,
|
||||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[dataExportDefaultTime, t],
|
||||
);
|
||||
|
||||
// ========== 初始化图表主题 ==========
|
||||
useEffect(() => {
|
||||
initVChartSemiTheme({
|
||||
@@ -434,14 +559,14 @@ export const useDashboardCharts = (
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 图表规格
|
||||
spec_pie,
|
||||
spec_line,
|
||||
spec_model_line,
|
||||
spec_rank_bar,
|
||||
|
||||
// 函数
|
||||
spec_user_rank,
|
||||
spec_user_trend,
|
||||
updateChartData,
|
||||
updateUserChartData,
|
||||
generateModelColors,
|
||||
};
|
||||
};
|
||||
|
||||
+22
@@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
}
|
||||
}, [activeUptimeTab]);
|
||||
|
||||
const loadUserQuotaData = useCallback(async () => {
|
||||
if (!isAdminUser) return [];
|
||||
try {
|
||||
const { start_timestamp, end_timestamp } = inputs;
|
||||
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
return data || [];
|
||||
} else {
|
||||
showError(message);
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return [];
|
||||
}
|
||||
}, [inputs, isAdminUser]);
|
||||
|
||||
const getUserData = useCallback(async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
@@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
showSearchModal,
|
||||
handleCloseModal,
|
||||
loadQuotaData,
|
||||
loadUserQuotaData,
|
||||
loadUptimeData,
|
||||
getUserData,
|
||||
refresh,
|
||||
|
||||
+43
-6
@@ -196,10 +196,17 @@ export const useApiRequest = (
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody = '';
|
||||
let parsedError = null;
|
||||
try {
|
||||
errorBody = await response.text();
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
if (errorJson?.error) {
|
||||
parsedError = errorJson.error;
|
||||
}
|
||||
} catch (e) {
|
||||
errorBody = '无法读取错误响应体';
|
||||
if (!errorBody) {
|
||||
errorBody = '无法读取错误响应体';
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = handleApiError(
|
||||
@@ -215,9 +222,13 @@ export const useApiRequest = (
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
throw new Error(
|
||||
`HTTP error! status: ${response.status}, body: ${errorBody}`,
|
||||
const err = new Error(
|
||||
parsedError?.message ||
|
||||
`HTTP error! status: ${response.status}, body: ${errorBody}`,
|
||||
);
|
||||
err.errorCode = parsedError?.code || null;
|
||||
err.errorType = parsedError?.type || null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -277,6 +288,7 @@ export const useApiRequest = (
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
content: t('请求发生错误: ') + error.message,
|
||||
errorCode: error.errorCode || null,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
...autoCollapseState,
|
||||
};
|
||||
@@ -379,7 +391,20 @@ export const useApiRequest = (
|
||||
// 只有在流没有正常完成且连接状态异常时才处理错误
|
||||
if (!isStreamComplete && source.readyState !== 2) {
|
||||
console.error('SSE Error:', e);
|
||||
const errorMessage = e.data || t('请求发生错误');
|
||||
let errorMessage = e.data || t('请求发生错误');
|
||||
let errorCode = null;
|
||||
|
||||
if (e.data) {
|
||||
try {
|
||||
const errorJson = JSON.parse(e.data);
|
||||
if (errorJson?.error) {
|
||||
errorMessage = errorJson.error.message || errorMessage;
|
||||
errorCode = errorJson.error.code || null;
|
||||
}
|
||||
} catch (_) {
|
||||
// not JSON, use raw data as error message
|
||||
}
|
||||
}
|
||||
|
||||
const errorInfo = handleApiError(new Error(errorMessage));
|
||||
errorInfo.readyState = source.readyState;
|
||||
@@ -393,8 +418,19 @@ export const useApiRequest = (
|
||||
}));
|
||||
setActiveDebugTab(DEBUG_TABS.RESPONSE);
|
||||
|
||||
streamMessageUpdate(errorMessage, 'content');
|
||||
completeMessage(MESSAGE_STATUS.ERROR);
|
||||
setMessage((prevMessage) => {
|
||||
const newMessages = [...prevMessage];
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (lastMessage && lastMessage.status !== MESSAGE_STATUS.COMPLETE && lastMessage.status !== MESSAGE_STATUS.ERROR) {
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
content: (lastMessage.content || '') + errorMessage,
|
||||
errorCode: errorCode,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
sseSourceRef.current = null;
|
||||
source.close();
|
||||
}
|
||||
@@ -446,6 +482,7 @@ export const useApiRequest = (
|
||||
[
|
||||
setDebugData,
|
||||
setActiveDebugTab,
|
||||
setMessage,
|
||||
streamMessageUpdate,
|
||||
completeMessage,
|
||||
t,
|
||||
|
||||
+8
-1
@@ -167,7 +167,14 @@ export const usePlaygroundState = () => {
|
||||
// 配置导入/重置
|
||||
const handleConfigImport = useCallback((importedConfig) => {
|
||||
if (importedConfig.inputs) {
|
||||
setInputs((prev) => ({ ...prev, ...importedConfig.inputs }));
|
||||
const parsedMaxTokens = parseInt(importedConfig.inputs.max_tokens, 10);
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
...importedConfig.inputs,
|
||||
max_tokens: Number.isNaN(parsedMaxTokens)
|
||||
? importedConfig.inputs.max_tokens
|
||||
: parsedMaxTokens,
|
||||
}));
|
||||
}
|
||||
if (importedConfig.parameterEnabled) {
|
||||
setParameterEnabled((prev) => ({
|
||||
|
||||
+23
-6
@@ -31,6 +31,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import {
|
||||
fetchTokenKey as fetchTokenKeyById,
|
||||
fetchTokenKeysBatch,
|
||||
getServerAddress,
|
||||
encodeChannelConnectionString,
|
||||
} from '../../helpers/token';
|
||||
@@ -41,6 +42,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
// Basic state
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groupRatios, setGroupRatios] = useState({});
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [tokenCount, setTokenCount] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
@@ -408,14 +410,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const keys = await Promise.all(
|
||||
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
|
||||
);
|
||||
const ids = selectedKeys.map((token) => token.id);
|
||||
const keysMap = await fetchTokenKeysBatch(ids);
|
||||
|
||||
setResolvedTokenKeys((prev) => ({ ...prev, ...keysMap }));
|
||||
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
const fullKey = keys[i];
|
||||
for (const token of selectedKeys) {
|
||||
const fullKey = keysMap[token.id];
|
||||
if (!fullKey) continue;
|
||||
if (copyType === 'name+key') {
|
||||
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
|
||||
content += `${token.name} sk-${fullKey}\n`;
|
||||
} else {
|
||||
content += `sk-${fullKey}\n`;
|
||||
}
|
||||
@@ -433,6 +438,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
API.get('/api/user/self/groups')
|
||||
.then((res) => {
|
||||
if (res.data.success && res.data.data) {
|
||||
const ratios = {};
|
||||
for (const [name, info] of Object.entries(res.data.data)) {
|
||||
ratios[name] = info.ratio;
|
||||
}
|
||||
setGroupRatios(ratios);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [pageSize]);
|
||||
|
||||
return {
|
||||
@@ -443,6 +459,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
tokenCount,
|
||||
pageSize,
|
||||
searching,
|
||||
groupRatios,
|
||||
|
||||
// Selection state
|
||||
selectedKeys,
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ import {
|
||||
renderAudioModelPrice,
|
||||
renderClaudeModelPrice,
|
||||
renderModelPrice,
|
||||
renderTaskBillingProcess,
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
@@ -497,7 +498,10 @@ export const useLogsData = () => {
|
||||
|
||||
let content = '';
|
||||
if (!isViolationFeeLog) {
|
||||
if (other?.ws || other?.audio) {
|
||||
const isTaskLog = other?.is_task === true || other?.task_id != null;
|
||||
if (isTaskLog && other?.model_price === -1) {
|
||||
content = renderTaskBillingProcess(other, logs[i].content);
|
||||
} else if (other?.ws || other?.audio) {
|
||||
content = renderAudioModelPrice(
|
||||
other?.text_input,
|
||||
other?.text_output,
|
||||
|
||||
Vendored
+465
-329
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user