mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-17 16:17:26 +00:00
Compare commits
131 Commits
v0.9.0-alp
...
v0.9.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e68eed3d40 | ||
|
|
04cc668430 | ||
|
|
5d76e16324 | ||
|
|
b6c547ae98 | ||
|
|
93adcd57d7 | ||
|
|
e813da59cc | ||
|
|
b25ac0bfb6 | ||
|
|
70c27bc662 | ||
|
|
db6a788e0d | ||
|
|
e3bc40f11b | ||
|
|
684caa3673 | ||
|
|
47aaa695b2 | ||
|
|
cda73a2ec5 | ||
|
|
27a0a447d0 | ||
|
|
fcdfd027cd | ||
|
|
3f9698bb47 | ||
|
|
041782c49e | ||
|
|
18077b6e87 | ||
|
|
c40a4f5444 | ||
|
|
028f0220dd | ||
|
|
a616aa3c89 | ||
|
|
1c12c73496 | ||
|
|
b29efbde52 | ||
|
|
b7527eb80e | ||
|
|
d05974fa3d | ||
|
|
a77a88308a | ||
|
|
e5a5d2de7c | ||
|
|
c0187d50ff | ||
|
|
3d0bf36981 | ||
|
|
e61c1dc738 | ||
|
|
91a627ddfc | ||
|
|
3064ff093a | ||
|
|
e2f736bd2d | ||
|
|
c6cf1b98f8 | ||
|
|
56fc3441da | ||
|
|
ebaaecb9d9 | ||
|
|
fa7ba4a390 | ||
|
|
29983e434f | ||
|
|
8c65264474 | ||
|
|
cd4b75f492 | ||
|
|
faad6bcd0c | ||
|
|
265a9ea78c | ||
|
|
aeab08099b | ||
|
|
d9f37d16f7 | ||
|
|
203abf4430 | ||
|
|
17024490e9 | ||
|
|
f7ae3621f4 | ||
|
|
5cbd9da3f5 | ||
|
|
daffba3641 | ||
|
|
860ab51434 | ||
|
|
1442666cc0 | ||
|
|
5ac9ebdebb | ||
|
|
a47a37d315 | ||
|
|
fbc19abd28 | ||
|
|
1f111a163a | ||
|
|
b601d8fd7c | ||
|
|
e98ca000f2 | ||
|
|
5351c28af8 | ||
|
|
e174861b96 | ||
|
|
247e029159 | ||
|
|
5cfc133413 | ||
|
|
c6f53e4cc8 | ||
|
|
c8acbdb363 | ||
|
|
3a3be21366 | ||
|
|
274da13a19 | ||
|
|
153994fe45 | ||
|
|
cdef6da9e9 | ||
|
|
9127449a7a | ||
|
|
8809c44443 | ||
|
|
6a87808612 | ||
|
|
105b86c660 | ||
|
|
b8b66c3900 | ||
|
|
bc5b9a5506 | ||
|
|
9c798dcd16 | ||
|
|
f5b8abc3f3 | ||
|
|
09cc127121 | ||
|
|
ac67d50616 | ||
|
|
86964bb426 | ||
|
|
c05dc07666 | ||
|
|
af94e11c7d | ||
|
|
0f86c4df9e | ||
|
|
5f0db18d3a | ||
|
|
919e6937ee | ||
|
|
64e23f02f7 | ||
|
|
fbe7f35a25 | ||
|
|
8cd0150a75 | ||
|
|
839aa401f0 | ||
|
|
4055777110 | ||
|
|
b3a99a2625 | ||
|
|
872f7a9648 | ||
|
|
b0c703935f | ||
|
|
621d2b0b6a | ||
|
|
e69520b7fb | ||
|
|
4b968d03a1 | ||
|
|
edc6679140 | ||
|
|
e732c58426 | ||
|
|
c5a1cbe755 | ||
|
|
35218609d9 | ||
|
|
7629ad553a | ||
|
|
7ddf3a112c | ||
|
|
034094c2d2 | ||
|
|
65ed6d9d5b | ||
|
|
4524f90ebd | ||
|
|
33dd326007 | ||
|
|
95b487c51e | ||
|
|
fcb03392d1 | ||
|
|
64a6168092 | ||
|
|
6a6edaa7cf | ||
|
|
a95d70cf93 | ||
|
|
3e01dc81ec | ||
|
|
e087c9fe9e | ||
|
|
33d601db82 | ||
|
|
eef73e3699 | ||
|
|
1cc07546cb | ||
|
|
e23f01f8d5 | ||
|
|
a3c2b28d6a | ||
|
|
289ed24899 | ||
|
|
98db907680 | ||
|
|
b1cc9050ff | ||
|
|
dc4f5750af | ||
|
|
d374a22b70 | ||
|
|
595ed6b40e | ||
|
|
c9f5b1de1a | ||
|
|
522f2d920b | ||
|
|
bef59929db | ||
|
|
ee6dd9179b | ||
|
|
3b26810c17 | ||
|
|
3768fc37da | ||
|
|
6cf84b118b | ||
|
|
f3b7ac508d | ||
|
|
cd7594f623 |
@@ -56,8 +56,6 @@
|
||||
# SESSION_SECRET=random_string
|
||||
|
||||
# 其他配置
|
||||
# 渠道测试频率(单位:秒)
|
||||
# CHANNEL_TEST_FREQUENCY=10
|
||||
# 生成默认token
|
||||
# GENERATE_DEFAULT_TOKEN=false
|
||||
# Cohere 安全设置
|
||||
|
||||
21
.github/workflows/pr-target-branch-check.yml
vendored
21
.github/workflows/pr-target-branch-check.yml
vendored
@@ -1,21 +0,0 @@
|
||||
name: Check PR Branching Strategy
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
jobs:
|
||||
check-branching-strategy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Enforce branching strategy
|
||||
run: |
|
||||
if [[ "${{ github.base_ref }}" == "main" ]]; then
|
||||
if [[ "${{ github.head_ref }}" != "alpha" ]]; then
|
||||
echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch."
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "${{ github.base_ref }}" != "alpha" ]]; then
|
||||
echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch."
|
||||
exit 1
|
||||
fi
|
||||
echo "Branching strategy check passed."
|
||||
@@ -96,7 +96,11 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 思考转内容功能
|
||||
17. 🔄 针对用户的模型限流功能
|
||||
18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
||||
18. 🔄 请求格式转换功能,支持以下三种格式转换:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
||||
1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
|
||||
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
|
||||
3. 支持的渠道:
|
||||
|
||||
@@ -2,7 +2,8 @@ package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/antlabs/pcopy"
|
||||
|
||||
"github.com/jinzhu/copier"
|
||||
)
|
||||
|
||||
func DeepCopy[T any](src *T) (*T, error) {
|
||||
@@ -10,12 +11,9 @@ func DeepCopy[T any](src *T) (*T, error) {
|
||||
return nil, fmt.Errorf("copy source cannot be nil")
|
||||
}
|
||||
var dst T
|
||||
err := pcopy.Copy(&dst, src)
|
||||
err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if &dst == nil {
|
||||
return nil, fmt.Errorf("copy result cannot be nil")
|
||||
}
|
||||
return &dst, nil
|
||||
}
|
||||
|
||||
@@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error {
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func GetJsonType(data json.RawMessage) string {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
firstChar := bytes.TrimSpace(data)[0]
|
||||
switch firstChar {
|
||||
case '{':
|
||||
return "object"
|
||||
case '[':
|
||||
return "array"
|
||||
case '"':
|
||||
return "string"
|
||||
case 't', 'f':
|
||||
return "boolean"
|
||||
case 'n':
|
||||
return "null"
|
||||
default:
|
||||
return "number"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +123,16 @@ func Interface2String(inter interface{}) string {
|
||||
return fmt.Sprintf("%d", inter.(int))
|
||||
case float64:
|
||||
return fmt.Sprintf("%f", inter.(float64))
|
||||
case bool:
|
||||
if inter.(bool) {
|
||||
return "true"
|
||||
} else {
|
||||
return "false"
|
||||
}
|
||||
case nil:
|
||||
return ""
|
||||
}
|
||||
return "Not Implemented"
|
||||
return fmt.Sprintf("%v", inter)
|
||||
}
|
||||
|
||||
func UnescapeHTML(x string) interface{} {
|
||||
@@ -257,32 +265,32 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get audio duration")
|
||||
}
|
||||
durationStr := string(bytes.TrimSpace(output))
|
||||
if durationStr == "N/A" {
|
||||
// Create a temporary output file name
|
||||
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to create temporary file")
|
||||
}
|
||||
tmpName := tmpFp.Name()
|
||||
// Close immediately so ffmpeg can open the file on Windows.
|
||||
_ = tmpFp.Close()
|
||||
defer os.Remove(tmpName)
|
||||
durationStr := string(bytes.TrimSpace(output))
|
||||
if durationStr == "N/A" {
|
||||
// Create a temporary output file name
|
||||
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to create temporary file")
|
||||
}
|
||||
tmpName := tmpFp.Name()
|
||||
// Close immediately so ffmpeg can open the file on Windows.
|
||||
_ = tmpFp.Close()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
|
||||
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
|
||||
if err := ffmpegCmd.Run(); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to run ffmpeg")
|
||||
}
|
||||
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
|
||||
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
|
||||
if err := ffmpegCmd.Run(); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to run ffmpeg")
|
||||
}
|
||||
|
||||
// Recalculate the duration of the new file
|
||||
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
|
||||
}
|
||||
durationStr = string(bytes.TrimSpace(output))
|
||||
}
|
||||
// Recalculate the duration of the new file
|
||||
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
|
||||
}
|
||||
durationStr = string(bytes.TrimSpace(output))
|
||||
}
|
||||
return strconv.ParseFloat(durationStr, 64)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
relayconstant "one-api/relay/constant"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/setting/operation_setting"
|
||||
"one-api/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -234,7 +235,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
|
||||
if resp != nil {
|
||||
httpResp = resp.(*http.Response)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
err := service.RelayErrorHandler(httpResp, true)
|
||||
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
|
||||
return testResult{
|
||||
context: c,
|
||||
localErr: err,
|
||||
@@ -477,15 +478,26 @@ func TestAllChannels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func AutomaticallyTestChannels(frequency int) {
|
||||
if frequency <= 0 {
|
||||
common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test")
|
||||
return
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Duration(frequency) * time.Minute)
|
||||
common.SysLog("testing all channels")
|
||||
_ = testAllChannels(false)
|
||||
common.SysLog("channel test finished")
|
||||
}
|
||||
var autoTestChannelsOnce sync.Once
|
||||
|
||||
func AutomaticallyTestChannels() {
|
||||
autoTestChannelsOnce.Do(func() {
|
||||
for {
|
||||
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
|
||||
time.Sleep(10 * time.Minute)
|
||||
continue
|
||||
}
|
||||
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
|
||||
common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
|
||||
for {
|
||||
time.Sleep(time.Duration(frequency) * time.Minute)
|
||||
common.SysLog("automatically testing all channels")
|
||||
_ = testAllChannels(false)
|
||||
common.SysLog("automatically channel test finished")
|
||||
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/dto"
|
||||
"one-api/model"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -380,6 +381,85 @@ func GetChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// GetChannelKey 验证2FA后获取渠道密钥
|
||||
func GetChannelKey(c *gin.Context) {
|
||||
type GetChannelKeyRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
var req GetChannelKeyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
channelId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取2FA记录并验证
|
||||
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if twoFA == nil || !twoFA.IsEnabled {
|
||||
common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥"))
|
||||
return
|
||||
}
|
||||
|
||||
// 统一的2FA验证逻辑
|
||||
if !validateTwoFactorAuth(twoFA, req.Code) {
|
||||
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取渠道信息(包含密钥)
|
||||
channel, err := model.GetChannelById(channelId, true)
|
||||
if err != nil {
|
||||
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if channel == nil {
|
||||
common.ApiError(c, fmt.Errorf("渠道不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
|
||||
|
||||
// 统一的成功响应格式
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "验证成功",
|
||||
"data": map[string]interface{}{
|
||||
"key": channel.Key,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// validateTwoFactorAuth 统一的2FA验证函数
|
||||
func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
|
||||
// 尝试验证TOTP
|
||||
if cleanCode, err := common.ValidateNumericCode(code); err == nil {
|
||||
if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试验证备用码
|
||||
if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// validateChannel 通用的渠道校验函数
|
||||
func validateChannel(channel *model.Channel, isAdd bool) error {
|
||||
// 校验 channel settings
|
||||
@@ -481,7 +561,7 @@ func AddChannel(c *gin.Context) {
|
||||
case "multi_to_single":
|
||||
addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
|
||||
addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
|
||||
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
|
||||
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
|
||||
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -506,7 +586,7 @@ func AddChannel(c *gin.Context) {
|
||||
}
|
||||
keys = []string{addChannelRequest.Channel.Key}
|
||||
case "batch":
|
||||
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
|
||||
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
|
||||
// multi json
|
||||
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
|
||||
if err != nil {
|
||||
@@ -761,7 +841,7 @@ func UpdateChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 处理 Vertex AI 的特殊情况
|
||||
if channel.Type == constant.ChannelTypeVertexAi {
|
||||
if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
|
||||
// 尝试解析新密钥为JSON数组
|
||||
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
|
||||
array, err := getVertexArrayKeys(channel.Key)
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestStatus(c *gin.Context) {
|
||||
func GetStatus(c *gin.Context) {
|
||||
|
||||
cs := console_setting.GetConsoleSetting()
|
||||
common.OptionMapRWMutex.RLock()
|
||||
defer common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
data := gin.H{
|
||||
"version": common.Version,
|
||||
@@ -89,6 +91,10 @@ func GetStatus(c *gin.Context) {
|
||||
"announcements_enabled": cs.AnnouncementsEnabled,
|
||||
"faq_enabled": cs.FAQEnabled,
|
||||
|
||||
// 模块管理配置
|
||||
"HeaderNavModules": common.OptionMap["HeaderNavModules"],
|
||||
"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
|
||||
|
||||
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
|
||||
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
|
||||
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
|
||||
|
||||
@@ -207,6 +207,7 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"data": userOpenAiModels,
|
||||
"object": "list",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
604
controller/model_sync.go
Normal file
604
controller/model_sync.go
Normal file
@@ -0,0 +1,604 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 上游地址
|
||||
const (
|
||||
upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
|
||||
upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
|
||||
)
|
||||
|
||||
func normalizeLocale(locale string) (string, bool) {
|
||||
l := strings.ToLower(strings.TrimSpace(locale))
|
||||
switch l {
|
||||
case "en", "zh", "ja":
|
||||
return l, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func getUpstreamBase() string {
|
||||
return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
|
||||
}
|
||||
|
||||
func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
|
||||
base := strings.TrimRight(getUpstreamBase(), "/")
|
||||
if l, ok := normalizeLocale(locale); ok && l != "" {
|
||||
return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
|
||||
fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
|
||||
}
|
||||
return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
|
||||
}
|
||||
|
||||
type upstreamEnvelope[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data []T `json:"data"`
|
||||
}
|
||||
|
||||
type upstreamModel struct {
|
||||
Description string `json:"description"`
|
||||
Endpoints json.RawMessage `json:"endpoints"`
|
||||
Icon string `json:"icon"`
|
||||
ModelName string `json:"model_name"`
|
||||
NameRule int `json:"name_rule"`
|
||||
Status int `json:"status"`
|
||||
Tags string `json:"tags"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
}
|
||||
|
||||
type upstreamVendor struct {
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
var (
|
||||
etagCache = make(map[string]string)
|
||||
bodyCache = make(map[string][]byte)
|
||||
cacheMutex sync.RWMutex
|
||||
)
|
||||
|
||||
type overwriteField struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Fields []string `json:"fields"`
|
||||
}
|
||||
|
||||
type syncRequest struct {
|
||||
Overwrite []overwriteField `json:"overwrite"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
func newHTTPClient() *http.Client {
|
||||
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
|
||||
dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
|
||||
}
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
if strings.HasSuffix(host, "github.io") {
|
||||
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
return dialer.DialContext(ctx, "tcp6", addr)
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
var httpClient = newHTTPClient()
|
||||
|
||||
func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
|
||||
var lastErr error
|
||||
attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3)
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
baseDelay := 200 * time.Millisecond
|
||||
maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
|
||||
maxBytes := int64(maxMB) << 20
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ETag conditional request
|
||||
cacheMutex.RLock()
|
||||
if et := etagCache[url]; et != "" {
|
||||
req.Header.Set("If-None-Match", et)
|
||||
}
|
||||
cacheMutex.RUnlock()
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// backoff with jitter
|
||||
sleep := baseDelay * time.Duration(1<<attempt)
|
||||
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
|
||||
time.Sleep(sleep + jitter)
|
||||
continue
|
||||
}
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
// read body into buffer for caching and flexible decode
|
||||
limited := io.LimitReader(resp.Body, maxBytes)
|
||||
buf, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
return
|
||||
}
|
||||
// cache body and ETag
|
||||
cacheMutex.Lock()
|
||||
if et := resp.Header.Get("ETag"); et != "" {
|
||||
etagCache[url] = et
|
||||
}
|
||||
bodyCache[url] = buf
|
||||
cacheMutex.Unlock()
|
||||
|
||||
// Try decode as envelope first
|
||||
if err := json.Unmarshal(buf, out); err != nil {
|
||||
// Try decode as pure array
|
||||
var arr []T
|
||||
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
|
||||
lastErr = err
|
||||
return
|
||||
}
|
||||
out.Success = true
|
||||
out.Data = arr
|
||||
out.Message = ""
|
||||
} else {
|
||||
if !out.Success && len(out.Data) == 0 && out.Message == "" {
|
||||
out.Success = true
|
||||
}
|
||||
}
|
||||
lastErr = nil
|
||||
case http.StatusNotModified:
|
||||
// use cache
|
||||
cacheMutex.RLock()
|
||||
buf := bodyCache[url]
|
||||
cacheMutex.RUnlock()
|
||||
if len(buf) == 0 {
|
||||
lastErr = errors.New("cache miss for 304 response")
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(buf, out); err != nil {
|
||||
var arr []T
|
||||
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
|
||||
lastErr = err
|
||||
return
|
||||
}
|
||||
out.Success = true
|
||||
out.Data = arr
|
||||
out.Message = ""
|
||||
} else {
|
||||
if !out.Success && len(out.Data) == 0 && out.Message == "" {
|
||||
out.Success = true
|
||||
}
|
||||
}
|
||||
lastErr = nil
|
||||
default:
|
||||
lastErr = errors.New(resp.Status)
|
||||
}
|
||||
}()
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
sleep := baseDelay * time.Duration(1<<attempt)
|
||||
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
|
||||
time.Sleep(sleep + jitter)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
|
||||
if vendorName == "" {
|
||||
return 0
|
||||
}
|
||||
if id, ok := vendorIDCache[vendorName]; ok {
|
||||
return id
|
||||
}
|
||||
var existing model.Vendor
|
||||
if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
|
||||
vendorIDCache[vendorName] = existing.Id
|
||||
return existing.Id
|
||||
}
|
||||
uv := vendorByName[vendorName]
|
||||
v := &model.Vendor{
|
||||
Name: vendorName,
|
||||
Description: uv.Description,
|
||||
Icon: coalesce(uv.Icon, ""),
|
||||
Status: chooseStatus(uv.Status, 1),
|
||||
}
|
||||
if err := v.Insert(); err == nil {
|
||||
*createdVendors++
|
||||
vendorIDCache[vendorName] = v.Id
|
||||
return v.Id
|
||||
}
|
||||
vendorIDCache[vendorName] = 0
|
||||
return 0
|
||||
}
|
||||
|
||||
// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
|
||||
func SyncUpstreamModels(c *gin.Context) {
|
||||
var req syncRequest
|
||||
// 允许空体
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
// 1) 获取未配置模型列表
|
||||
missing, err := model.GetMissingModels()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"created_models": 0,
|
||||
"created_vendors": 0,
|
||||
"skipped_models": []string{},
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 拉取上游 vendors 与 models
|
||||
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
|
||||
var vendorsEnv upstreamEnvelope[upstreamVendor]
|
||||
var modelsEnv upstreamEnvelope[upstreamModel]
|
||||
var fetchErr error
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// vendor 失败不拦截
|
||||
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
|
||||
fetchErr = err
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
if fetchErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": req.Locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
|
||||
return
|
||||
}
|
||||
|
||||
// 建立映射
|
||||
vendorByName := make(map[string]upstreamVendor)
|
||||
for _, v := range vendorsEnv.Data {
|
||||
if v.Name != "" {
|
||||
vendorByName[v.Name] = v
|
||||
}
|
||||
}
|
||||
modelByName := make(map[string]upstreamModel)
|
||||
for _, m := range modelsEnv.Data {
|
||||
if m.ModelName != "" {
|
||||
modelByName[m.ModelName] = m
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
|
||||
createdModels := 0
|
||||
createdVendors := 0
|
||||
updatedModels := 0
|
||||
var skipped []string
|
||||
var createdList []string
|
||||
var updatedList []string
|
||||
|
||||
// 本地缓存:vendorName -> id
|
||||
vendorIDCache := make(map[string]int)
|
||||
|
||||
for _, name := range missing {
|
||||
up, ok := modelByName[name]
|
||||
if !ok {
|
||||
skipped = append(skipped, name)
|
||||
continue
|
||||
}
|
||||
|
||||
// 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时)
|
||||
var existing model.Model
|
||||
if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil {
|
||||
if existing.SyncOfficial == 0 {
|
||||
skipped = append(skipped, name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 vendor 存在
|
||||
vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
|
||||
|
||||
// 创建模型
|
||||
mi := &model.Model{
|
||||
ModelName: name,
|
||||
Description: up.Description,
|
||||
Icon: up.Icon,
|
||||
Tags: up.Tags,
|
||||
VendorID: vendorID,
|
||||
Status: chooseStatus(up.Status, 1),
|
||||
NameRule: up.NameRule,
|
||||
}
|
||||
if err := mi.Insert(); err == nil {
|
||||
createdModels++
|
||||
createdList = append(createdList, name)
|
||||
} else {
|
||||
skipped = append(skipped, name)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 处理可选覆盖(更新本地已有模型的差异字段)
|
||||
if len(req.Overwrite) > 0 {
|
||||
// vendorIDCache 已用于创建阶段,可复用
|
||||
for _, ow := range req.Overwrite {
|
||||
up, ok := modelByName[ow.ModelName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var local model.Model
|
||||
if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过被禁用官方同步的模型
|
||||
if local.SyncOfficial == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 映射 vendor
|
||||
newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
|
||||
|
||||
// 应用字段覆盖(事务)
|
||||
_ = model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
needUpdate := false
|
||||
if containsField(ow.Fields, "description") {
|
||||
local.Description = up.Description
|
||||
needUpdate = true
|
||||
}
|
||||
if containsField(ow.Fields, "icon") {
|
||||
local.Icon = up.Icon
|
||||
needUpdate = true
|
||||
}
|
||||
if containsField(ow.Fields, "tags") {
|
||||
local.Tags = up.Tags
|
||||
needUpdate = true
|
||||
}
|
||||
if containsField(ow.Fields, "vendor") {
|
||||
local.VendorID = newVendorID
|
||||
needUpdate = true
|
||||
}
|
||||
if containsField(ow.Fields, "name_rule") {
|
||||
local.NameRule = up.NameRule
|
||||
needUpdate = true
|
||||
}
|
||||
if containsField(ow.Fields, "status") {
|
||||
local.Status = chooseStatus(up.Status, local.Status)
|
||||
needUpdate = true
|
||||
}
|
||||
if !needUpdate {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Save(&local).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updatedModels++
|
||||
updatedList = append(updatedList, ow.ModelName)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"created_models": createdModels,
|
||||
"created_vendors": createdVendors,
|
||||
"updated_models": updatedModels,
|
||||
"skipped_models": skipped,
|
||||
"created_list": createdList,
|
||||
"updated_list": updatedList,
|
||||
"source": gin.H{
|
||||
"locale": req.Locale,
|
||||
"models_url": modelsURL,
|
||||
"vendors_url": vendorsURL,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func containsField(fields []string, key string) bool {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
for _, f := range fields {
|
||||
if strings.ToLower(strings.TrimSpace(f)) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func coalesce(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func chooseStatus(primary, fallback int) int {
|
||||
if primary == 0 && fallback != 0 {
|
||||
return fallback
|
||||
}
|
||||
if primary != 0 {
|
||||
return primary
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择)
|
||||
func SyncUpstreamPreview(c *gin.Context) {
|
||||
// 1) 拉取上游数据
|
||||
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
locale := c.Query("locale")
|
||||
modelsURL, vendorsURL := getUpstreamURLs(locale)
|
||||
|
||||
var vendorsEnv upstreamEnvelope[upstreamVendor]
|
||||
var modelsEnv upstreamEnvelope[upstreamModel]
|
||||
var fetchErr error
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
|
||||
fetchErr = err
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
if fetchErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
|
||||
return
|
||||
}
|
||||
|
||||
vendorByName := make(map[string]upstreamVendor)
|
||||
for _, v := range vendorsEnv.Data {
|
||||
if v.Name != "" {
|
||||
vendorByName[v.Name] = v
|
||||
}
|
||||
}
|
||||
modelByName := make(map[string]upstreamModel)
|
||||
upstreamNames := make([]string, 0, len(modelsEnv.Data))
|
||||
for _, m := range modelsEnv.Data {
|
||||
if m.ModelName != "" {
|
||||
modelByName[m.ModelName] = m
|
||||
upstreamNames = append(upstreamNames, m.ModelName)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 本地已有模型
|
||||
var locals []model.Model
|
||||
if len(upstreamNames) > 0 {
|
||||
_ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error
|
||||
}
|
||||
|
||||
// 本地 vendor 名称映射
|
||||
vendorIdSet := make(map[int]struct{})
|
||||
for _, m := range locals {
|
||||
if m.VendorID != 0 {
|
||||
vendorIdSet[m.VendorID] = struct{}{}
|
||||
}
|
||||
}
|
||||
vendorIDs := make([]int, 0, len(vendorIdSet))
|
||||
for id := range vendorIdSet {
|
||||
vendorIDs = append(vendorIDs, id)
|
||||
}
|
||||
idToVendorName := make(map[int]string)
|
||||
if len(vendorIDs) > 0 {
|
||||
var dbVendors []model.Vendor
|
||||
_ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error
|
||||
for _, v := range dbVendors {
|
||||
idToVendorName[v.Id] = v.Name
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 缺失且上游存在的模型
|
||||
missingList, _ := model.GetMissingModels()
|
||||
var missing []string
|
||||
for _, name := range missingList {
|
||||
if _, ok := modelByName[name]; ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 计算冲突字段
|
||||
type conflictField struct {
|
||||
Field string `json:"field"`
|
||||
Local interface{} `json:"local"`
|
||||
Upstream interface{} `json:"upstream"`
|
||||
}
|
||||
type conflictItem struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Fields []conflictField `json:"fields"`
|
||||
}
|
||||
|
||||
var conflicts []conflictItem
|
||||
for _, local := range locals {
|
||||
up, ok := modelByName[local.ModelName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := make([]conflictField, 0, 6)
|
||||
if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) {
|
||||
fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description})
|
||||
}
|
||||
if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) {
|
||||
fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon})
|
||||
}
|
||||
if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) {
|
||||
fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags})
|
||||
}
|
||||
// vendor 对比使用名称
|
||||
localVendor := idToVendorName[local.VendorID]
|
||||
if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) {
|
||||
fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName})
|
||||
}
|
||||
if local.NameRule != up.NameRule {
|
||||
fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule})
|
||||
}
|
||||
if local.Status != chooseStatus(up.Status, local.Status) {
|
||||
fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status})
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"missing": missing,
|
||||
"conflicts": conflicts,
|
||||
"source": gin.H{
|
||||
"locale": locale,
|
||||
"models_url": modelsURL,
|
||||
"vendors_url": vendorsURL,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
@@ -35,8 +36,13 @@ func GetOptions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
type OptionUpdateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
func UpdateOption(c *gin.Context) {
|
||||
var option model.Option
|
||||
var option OptionUpdateRequest
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&option)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -45,6 +51,16 @@ func UpdateOption(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
switch option.Value.(type) {
|
||||
case bool:
|
||||
option.Value = common.Interface2String(option.Value.(bool))
|
||||
case float64:
|
||||
option.Value = common.Interface2String(option.Value.(float64))
|
||||
case int:
|
||||
option.Value = common.Interface2String(option.Value.(int))
|
||||
default:
|
||||
option.Value = fmt.Sprintf("%v", option.Value)
|
||||
}
|
||||
switch option.Key {
|
||||
case "GitHubOAuthEnabled":
|
||||
if option.Value == "true" && common.GitHubClientId == "" {
|
||||
@@ -104,7 +120,7 @@ func UpdateOption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
case "GroupRatio":
|
||||
err = ratio_setting.CheckGroupRatio(option.Value)
|
||||
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -113,7 +129,7 @@ func UpdateOption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
case "ModelRequestRateLimitGroup":
|
||||
err = setting.CheckModelRequestRateLimitGroup(option.Value)
|
||||
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -122,7 +138,7 @@ func UpdateOption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
case "console_setting.api_info":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -131,7 +147,7 @@ func UpdateOption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
case "console_setting.announcements":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -140,7 +156,7 @@ func UpdateOption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
case "console_setting.faq":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -149,7 +165,7 @@ func UpdateOption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
case "console_setting.uptime_kuma_groups":
|
||||
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
|
||||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -158,7 +174,7 @@ func UpdateOption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
err = model.UpdateOption(option.Key, option.Value)
|
||||
err = model.UpdateOption(option.Key, option.Value.(string))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"one-api/setting/ratio_setting"
|
||||
"net/http"
|
||||
"one-api/setting/ratio_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetRatioConfig(c *gin.Context) {
|
||||
if !ratio_setting.IsExposeRatioEnabled() {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "倍率配置接口未启用",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !ratio_setting.IsExposeRatioEnabled() {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "倍率配置接口未启用",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": ratio_setting.GetExposedData(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": ratio_setting.GetExposedData(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"one-api/logger"
|
||||
"strings"
|
||||
@@ -21,8 +23,26 @@ const (
|
||||
defaultTimeoutSeconds = 10
|
||||
defaultEndpoint = "/api/ratio_config"
|
||||
maxConcurrentFetches = 8
|
||||
maxRatioConfigBytes = 10 << 20 // 10MB
|
||||
floatEpsilon = 1e-9
|
||||
)
|
||||
|
||||
func nearlyEqual(a, b float64) bool {
|
||||
if a > b {
|
||||
return a-b < floatEpsilon
|
||||
}
|
||||
return b-a < floatEpsilon
|
||||
}
|
||||
|
||||
func valuesEqual(a, b interface{}) bool {
|
||||
af, aok := a.(float64)
|
||||
bf, bok := b.(float64)
|
||||
if aok && bok {
|
||||
return nearlyEqual(af, bf)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
|
||||
|
||||
type upstreamResult struct {
|
||||
@@ -87,7 +107,23 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
|
||||
sem := make(chan struct{}, maxConcurrentFetches)
|
||||
|
||||
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
// 对 github.io 优先尝试 IPv4,失败则回退 IPv6
|
||||
if strings.HasSuffix(host, "github.io") {
|
||||
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
return dialer.DialContext(ctx, "tcp6", addr)
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
for _, chn := range upstreams {
|
||||
wg.Add(1)
|
||||
@@ -98,12 +134,17 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
defer func() { <-sem }()
|
||||
|
||||
endpoint := chItem.Endpoint
|
||||
if endpoint == "" {
|
||||
endpoint = defaultEndpoint
|
||||
} else if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
var fullURL string
|
||||
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
||||
fullURL = endpoint
|
||||
} else {
|
||||
if endpoint == "" {
|
||||
endpoint = defaultEndpoint
|
||||
} else if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
}
|
||||
fullURL = chItem.BaseURL + endpoint
|
||||
}
|
||||
fullURL := chItem.BaseURL + endpoint
|
||||
|
||||
uniqueName := chItem.Name
|
||||
if chItem.ID != 0 {
|
||||
@@ -120,10 +161,19 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
// 简单重试:最多 3 次,指数退避
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
resp, lastErr = client.Do(httpReq)
|
||||
if lastErr == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
|
||||
}
|
||||
if lastErr != nil {
|
||||
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
|
||||
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -132,6 +182,12 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
|
||||
return
|
||||
}
|
||||
|
||||
// Content-Type 和响应体大小校验
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
|
||||
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
|
||||
}
|
||||
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
|
||||
// 兼容两种上游接口格式:
|
||||
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
|
||||
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
|
||||
@@ -141,7 +197,7 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
if err := json.NewDecoder(limited).Decode(&body); err != nil {
|
||||
logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
|
||||
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
|
||||
return
|
||||
@@ -152,6 +208,8 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容)
|
||||
|
||||
// 尝试按 type1 解析
|
||||
var type1Data map[string]any
|
||||
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
|
||||
@@ -357,9 +415,9 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
|
||||
upstreamValue = val
|
||||
hasUpstreamValue = true
|
||||
|
||||
if localValue != nil && localValue != val {
|
||||
if localValue != nil && !valuesEqual(localValue, val) {
|
||||
hasDifference = true
|
||||
} else if localValue == val {
|
||||
} else if valuesEqual(localValue, val) {
|
||||
upstreamValue = "same"
|
||||
}
|
||||
}
|
||||
@@ -466,6 +524,13 @@ func GetSyncableChannels(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
syncableChannels = append(syncableChannels, dto.SyncableChannel{
|
||||
ID: -100,
|
||||
Name: "官方倍率预设",
|
||||
BaseURL: "https://basellm.github.io",
|
||||
Status: 1,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
||||
@@ -3,7 +3,6 @@ package controller
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -22,6 +21,8 @@ import (
|
||||
"one-api/types"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -138,15 +139,15 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
|
||||
// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
|
||||
|
||||
preConsumedQuota, newAPIError := service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
|
||||
newAPIError = service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
|
||||
if newAPIError != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Only return quota if downstream failed and quota was actually pre-consumed
|
||||
if newAPIError != nil && preConsumedQuota != 0 {
|
||||
service.ReturnPreConsumedQuota(c, relayInfo, preConsumedQuota)
|
||||
if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 {
|
||||
service.ReturnPreConsumedQuota(c, relayInfo)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -276,14 +277,13 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
||||
|
||||
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
|
||||
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
|
||||
|
||||
gopool.Go(func() {
|
||||
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
|
||||
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
|
||||
gopool.Go(func() {
|
||||
service.DisableChannel(channelError, err.Error())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
|
||||
// 保存错误日志到mysql中
|
||||
@@ -383,11 +383,14 @@ func RelayNotFound(c *gin.Context) {
|
||||
func RelayTask(c *gin.Context) {
|
||||
retryTimes := common.RetryTimes
|
||||
channelId := c.GetInt("channel_id")
|
||||
relayMode := c.GetInt("relay_mode")
|
||||
group := c.GetString("group")
|
||||
originalModel := c.GetString("original_model")
|
||||
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
|
||||
taskErr := taskRelayHandler(c, relayMode)
|
||||
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
taskErr := taskRelayHandler(c, relayInfo)
|
||||
if taskErr == nil {
|
||||
retryTimes = 0
|
||||
}
|
||||
@@ -407,7 +410,7 @@ func RelayTask(c *gin.Context) {
|
||||
|
||||
requestBody, _ := common.GetRequestBody(c)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
taskErr = taskRelayHandler(c, relayMode)
|
||||
taskErr = taskRelayHandler(c, relayInfo)
|
||||
}
|
||||
useChannel := c.GetStringSlice("use_channel")
|
||||
if len(useChannel) > 1 {
|
||||
@@ -422,13 +425,13 @@ func RelayTask(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError {
|
||||
func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError {
|
||||
var err *dto.TaskError
|
||||
switch relayMode {
|
||||
switch relayInfo.RelayMode {
|
||||
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
|
||||
err = relay.RelayTaskFetch(c, relayMode)
|
||||
err = relay.RelayTaskFetch(c, relayInfo.RelayMode)
|
||||
default:
|
||||
err = relay.RelayTaskSubmit(c, relayMode)
|
||||
err = relay.RelayTaskSubmit(c, relayInfo)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
task.StartTime = now
|
||||
}
|
||||
case model.TaskStatusSuccess:
|
||||
task.Progress = "100%"
|
||||
task.Progress = "100%"
|
||||
if task.FinishTime == 0 {
|
||||
task.FinishTime = now
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type Monitor struct {
|
||||
|
||||
type UptimeGroupResult struct {
|
||||
CategoryName string `json:"categoryName"`
|
||||
Monitors []Monitor `json:"monitors"`
|
||||
Monitors []Monitor `json:"monitors"`
|
||||
}
|
||||
|
||||
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
|
||||
@@ -57,29 +57,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
|
||||
url, _ := groupConfig["url"].(string)
|
||||
slug, _ := groupConfig["slug"].(string)
|
||||
categoryName, _ := groupConfig["categoryName"].(string)
|
||||
|
||||
|
||||
result := UptimeGroupResult{
|
||||
CategoryName: categoryName,
|
||||
Monitors: []Monitor{},
|
||||
Monitors: []Monitor{},
|
||||
}
|
||||
|
||||
|
||||
if url == "" || slug == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(url, "/")
|
||||
|
||||
|
||||
var statusData struct {
|
||||
PublicGroupList []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MonitorList []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"monitorList"`
|
||||
} `json:"publicGroupList"`
|
||||
}
|
||||
|
||||
|
||||
var heartbeatData struct {
|
||||
HeartbeatList map[string][]struct {
|
||||
Status int `json:"status"`
|
||||
@@ -88,11 +88,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error {
|
||||
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
|
||||
g.Go(func() error {
|
||||
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
|
||||
})
|
||||
g.Go(func() error {
|
||||
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
|
||||
g.Go(func() error {
|
||||
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
|
||||
})
|
||||
|
||||
if g.Wait() != nil {
|
||||
@@ -139,7 +139,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
|
||||
|
||||
client := &http.Client{Timeout: httpTimeout}
|
||||
results := make([]UptimeGroupResult, len(groups))
|
||||
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
for i, group := range groups {
|
||||
i, group := i, group
|
||||
@@ -148,7 +148,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
g.Wait()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ func Register(c *gin.Context) {
|
||||
Password: user.Password,
|
||||
DisplayName: user.Username,
|
||||
InviterId: inviterId,
|
||||
Role: common.RoleCommonUser, // 明确设置角色为普通用户
|
||||
}
|
||||
if common.EmailVerificationEnabled {
|
||||
cleanUser.Email = user.Email
|
||||
@@ -426,6 +427,7 @@ func GetAffCode(c *gin.Context) {
|
||||
|
||||
func GetSelf(c *gin.Context) {
|
||||
id := c.GetInt("id")
|
||||
userRole := c.GetInt("role")
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -434,14 +436,134 @@ func GetSelf(c *gin.Context) {
|
||||
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
|
||||
user.Remark = ""
|
||||
|
||||
// 计算用户权限信息
|
||||
permissions := calculateUserPermissions(userRole)
|
||||
|
||||
// 获取用户设置并提取sidebar_modules
|
||||
userSetting := user.GetSetting()
|
||||
|
||||
// 构建响应数据,包含用户信息和权限
|
||||
responseData := map[string]interface{}{
|
||||
"id": user.Id,
|
||||
"username": user.Username,
|
||||
"display_name": user.DisplayName,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"email": user.Email,
|
||||
"group": user.Group,
|
||||
"quota": user.Quota,
|
||||
"used_quota": user.UsedQuota,
|
||||
"request_count": user.RequestCount,
|
||||
"aff_code": user.AffCode,
|
||||
"aff_count": user.AffCount,
|
||||
"aff_quota": user.AffQuota,
|
||||
"aff_history_quota": user.AffHistoryQuota,
|
||||
"inviter_id": user.InviterId,
|
||||
"linux_do_id": user.LinuxDOId,
|
||||
"setting": user.Setting,
|
||||
"stripe_customer": user.StripeCustomer,
|
||||
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
|
||||
"permissions": permissions, // 新增权限字段
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": user,
|
||||
"data": responseData,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 计算用户权限的辅助函数
|
||||
func calculateUserPermissions(userRole int) map[string]interface{} {
|
||||
permissions := map[string]interface{}{}
|
||||
|
||||
// 根据用户角色计算权限
|
||||
if userRole == common.RoleRootUser {
|
||||
// 超级管理员不需要边栏设置功能
|
||||
permissions["sidebar_settings"] = false
|
||||
permissions["sidebar_modules"] = map[string]interface{}{}
|
||||
} else if userRole == common.RoleAdminUser {
|
||||
// 管理员可以设置边栏,但不包含系统设置功能
|
||||
permissions["sidebar_settings"] = true
|
||||
permissions["sidebar_modules"] = map[string]interface{}{
|
||||
"admin": map[string]interface{}{
|
||||
"setting": false, // 管理员不能访问系统设置
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// 普通用户只能设置个人功能,不包含管理员区域
|
||||
permissions["sidebar_settings"] = true
|
||||
permissions["sidebar_modules"] = map[string]interface{}{
|
||||
"admin": false, // 普通用户不能访问管理员区域
|
||||
}
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
// 根据用户角色生成默认的边栏配置
|
||||
func generateDefaultSidebarConfig(userRole int) string {
|
||||
defaultConfig := map[string]interface{}{}
|
||||
|
||||
// 聊天区域 - 所有用户都可以访问
|
||||
defaultConfig["chat"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"playground": true,
|
||||
"chat": true,
|
||||
}
|
||||
|
||||
// 控制台区域 - 所有用户都可以访问
|
||||
defaultConfig["console"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"detail": true,
|
||||
"token": true,
|
||||
"log": true,
|
||||
"midjourney": true,
|
||||
"task": true,
|
||||
}
|
||||
|
||||
// 个人中心区域 - 所有用户都可以访问
|
||||
defaultConfig["personal"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"topup": true,
|
||||
"personal": true,
|
||||
}
|
||||
|
||||
// 管理员区域 - 根据角色决定
|
||||
if userRole == common.RoleAdminUser {
|
||||
// 管理员可以访问管理员区域,但不能访问系统设置
|
||||
defaultConfig["admin"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"channel": true,
|
||||
"models": true,
|
||||
"redemption": true,
|
||||
"user": true,
|
||||
"setting": false, // 管理员不能访问系统设置
|
||||
}
|
||||
} else if userRole == common.RoleRootUser {
|
||||
// 超级管理员可以访问所有功能
|
||||
defaultConfig["admin"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"channel": true,
|
||||
"models": true,
|
||||
"redemption": true,
|
||||
"user": true,
|
||||
"setting": true,
|
||||
}
|
||||
}
|
||||
// 普通用户不包含admin区域
|
||||
|
||||
// 转换为JSON字符串
|
||||
configBytes, err := json.Marshal(defaultConfig)
|
||||
if err != nil {
|
||||
common.SysLog("生成默认边栏配置失败: " + err.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(configBytes)
|
||||
}
|
||||
|
||||
func GetUserModels(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -528,8 +650,8 @@ func UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
func UpdateSelf(c *gin.Context) {
|
||||
var user model.User
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&user)
|
||||
var requestData map[string]interface{}
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
@@ -537,6 +659,60 @@ func UpdateSelf(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是sidebar_modules更新请求
|
||||
if sidebarModules, exists := requestData["sidebar_modules"]; exists {
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户设置
|
||||
currentSetting := user.GetSetting()
|
||||
|
||||
// 更新sidebar_modules字段
|
||||
if sidebarModulesStr, ok := sidebarModules.(string); ok {
|
||||
currentSetting.SidebarModules = sidebarModulesStr
|
||||
}
|
||||
|
||||
// 保存更新后的设置
|
||||
user.SetSetting(currentSetting)
|
||||
if err := user.Update(false); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "更新设置失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "设置更新成功",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 原有的用户信息更新逻辑
|
||||
var user model.User
|
||||
requestDataBytes, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(requestDataBytes, &user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Password == "" {
|
||||
user.Password = "$I_LOVE_U" // make Validator happy :)
|
||||
}
|
||||
@@ -679,6 +855,7 @@ func CreateUser(c *gin.Context) {
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
DisplayName: user.DisplayName,
|
||||
Role: user.Role, // 保持管理员设置的角色
|
||||
}
|
||||
if err := cleanUser.Insert(0); err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -920,6 +1097,7 @@ type UpdateUserSettingRequest struct {
|
||||
WebhookUrl string `json:"webhook_url,omitempty"`
|
||||
WebhookSecret string `json:"webhook_secret,omitempty"`
|
||||
NotificationEmail string `json:"notification_email,omitempty"`
|
||||
BarkUrl string `json:"bark_url,omitempty"`
|
||||
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
|
||||
RecordIpLog bool `json:"record_ip_log"`
|
||||
}
|
||||
@@ -935,7 +1113,7 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 验证预警类型
|
||||
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook {
|
||||
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的预警类型",
|
||||
@@ -983,6 +1161,33 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是Bark类型,验证Bark URL
|
||||
if req.QuotaWarningType == dto.NotifyTypeBark {
|
||||
if req.BarkUrl == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Bark推送URL不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 验证URL格式
|
||||
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的Bark推送URL",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 检查是否是HTTP或HTTPS
|
||||
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Bark推送URL必须以http://或https://开头",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, true)
|
||||
if err != nil {
|
||||
@@ -1011,6 +1216,11 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
settings.NotificationEmail = req.NotificationEmail
|
||||
}
|
||||
|
||||
// 如果是Bark类型,添加Bark URL到设置中
|
||||
if req.QuotaWarningType == dto.NotifyTypeBark {
|
||||
settings.BarkUrl = req.BarkUrl
|
||||
}
|
||||
|
||||
// 更新用户设置
|
||||
user.SetSetting(settings)
|
||||
if err := user.Update(false); err != nil {
|
||||
|
||||
@@ -9,6 +9,14 @@ type ChannelSettings struct {
|
||||
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
|
||||
}
|
||||
|
||||
type VertexKeyType string
|
||||
|
||||
const (
|
||||
VertexKeyTypeJSON VertexKeyType = "json"
|
||||
VertexKeyTypeAPIKey VertexKeyType = "api_key"
|
||||
)
|
||||
|
||||
type ChannelOtherSettings struct {
|
||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||
}
|
||||
|
||||
@@ -488,14 +488,14 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
|
||||
case string:
|
||||
// 处理简单字符串错误
|
||||
return &types.ClaudeError{
|
||||
Type: "error",
|
||||
Type: "upstream_error",
|
||||
Message: err,
|
||||
}
|
||||
default:
|
||||
// 未知类型,尝试转换为字符串
|
||||
return &types.ClaudeError{
|
||||
Type: "unknown_error",
|
||||
Message: fmt.Sprintf("%v", err),
|
||||
Type: "unknown_upstream_error",
|
||||
Message: fmt.Sprintf("unknown_error: %v", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"one-api/common"
|
||||
"one-api/logger"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GeminiChatRequest struct {
|
||||
@@ -268,14 +269,15 @@ type GeminiChatResponse struct {
|
||||
}
|
||||
|
||||
type GeminiUsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
PromptTokensDetails []GeminiModalityTokenCount `json:"promptTokensDetails"`
|
||||
CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails"`
|
||||
}
|
||||
|
||||
type GeminiPromptTokensDetails struct {
|
||||
type GeminiModalityTokenCount struct {
|
||||
Modality string `json:"modality"`
|
||||
TokenCount int `json:"tokenCount"`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"one-api/common"
|
||||
"one-api/types"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -29,6 +31,93 @@ type ImageRequest struct {
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (i *ImageRequest) UnmarshalJSON(data []byte) error {
|
||||
// 先解析成 map[string]interface{}
|
||||
var rawMap map[string]json.RawMessage
|
||||
if err := common.Unmarshal(data, &rawMap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 用 struct tag 获取所有已定义字段名
|
||||
knownFields := GetJSONFieldNames(reflect.TypeOf(*i))
|
||||
|
||||
// 再正常解析已定义字段
|
||||
type Alias ImageRequest
|
||||
var known Alias
|
||||
if err := common.Unmarshal(data, &known); err != nil {
|
||||
return err
|
||||
}
|
||||
*i = ImageRequest(known)
|
||||
|
||||
// 提取多余字段
|
||||
i.Extra = make(map[string]json.RawMessage)
|
||||
for k, v := range rawMap {
|
||||
if _, ok := knownFields[k]; !ok {
|
||||
i.Extra[k] = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 序列化时需要重新把字段平铺
|
||||
func (r ImageRequest) MarshalJSON() ([]byte, error) {
|
||||
// 将已定义字段转为 map
|
||||
type Alias ImageRequest
|
||||
alias := Alias(r)
|
||||
base, err := common.Marshal(alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var baseMap map[string]json.RawMessage
|
||||
if err := common.Unmarshal(base, &baseMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 合并 ExtraFields
|
||||
for k, v := range r.Extra {
|
||||
if _, exists := baseMap[k]; !exists {
|
||||
baseMap[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(baseMap)
|
||||
}
|
||||
|
||||
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
|
||||
fields := make(map[string]struct{})
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
|
||||
// 跳过匿名字段(例如 ExtraFields)
|
||||
if field.Anonymous {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := field.Tag.Get("json")
|
||||
if tag == "-" || tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 取逗号前字段名(排除 omitempty 等)
|
||||
name := tag
|
||||
if commaIdx := indexComma(tag); commaIdx != -1 {
|
||||
name = tag[:commaIdx]
|
||||
}
|
||||
fields[name] = struct{}{}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func indexComma(s string) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ',' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
var sizeRatio = 1.0
|
||||
var qualityRatio = 1.0
|
||||
|
||||
@@ -57,18 +57,24 @@ type GeneralOpenAIRequest struct {
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
Modalities json.RawMessage `json:"modalities,omitempty"`
|
||||
Audio json.RawMessage `json:"audio,omitempty"`
|
||||
EnableThinking any `json:"enable_thinking,omitempty"` // ali
|
||||
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao,zhipu_v4
|
||||
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
|
||||
SearchParameters any `json:"search_parameters,omitempty"` //xai
|
||||
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
|
||||
// gemini
|
||||
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
|
||||
//xai
|
||||
SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
|
||||
// claude
|
||||
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
|
||||
// OpenRouter Params
|
||||
Usage json.RawMessage `json:"usage,omitempty"`
|
||||
Reasoning json.RawMessage `json:"reasoning,omitempty"`
|
||||
// Ali Qwen Params
|
||||
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
|
||||
// 用匿名参数接收额外参数,例如ollama的think参数在此接收
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
EnableThinking any `json:"enable_thinking,omitempty"`
|
||||
// ollama Params
|
||||
Think json.RawMessage `json:"think,omitempty"`
|
||||
// baidu v2
|
||||
WebSearch json.RawMessage `json:"web_search,omitempty"`
|
||||
// doubao,zhipu_v4
|
||||
THINKING json.RawMessage `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
@@ -760,27 +766,27 @@ type WebSearchOptions struct {
|
||||
|
||||
// https://platform.openai.com/docs/api-reference/responses/create
|
||||
type OpenAIResponsesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input any `json:"input,omitempty"`
|
||||
Include json.RawMessage `json:"include,omitempty"`
|
||||
Instructions json.RawMessage `json:"instructions,omitempty"`
|
||||
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Store bool `json:"store,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools []map[string]any `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Include json.RawMessage `json:"include,omitempty"`
|
||||
Instructions json.RawMessage `json:"instructions,omitempty"`
|
||||
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Store bool `json:"store,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
@@ -832,8 +838,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
|
||||
if len(r.Tools) > 0 {
|
||||
toolStr, _ := common.Marshal(r.Tools)
|
||||
texts = append(texts, string(toolStr))
|
||||
texts = append(texts, string(r.Tools))
|
||||
}
|
||||
|
||||
return &types.TokenCountMeta{
|
||||
@@ -853,6 +858,14 @@ func (r *OpenAIResponsesRequest) SetModelName(modelName string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OpenAIResponsesRequest) GetToolsMap() []map[string]any {
|
||||
var toolsMap []map[string]any
|
||||
if len(r.Tools) > 0 {
|
||||
_ = common.Unmarshal(r.Tools, &toolsMap)
|
||||
}
|
||||
return toolsMap
|
||||
}
|
||||
|
||||
type Reasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
@@ -879,13 +892,21 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
var inputs []MediaInput
|
||||
|
||||
// Try string first
|
||||
if str, ok := r.Input.(string); ok {
|
||||
// if str, ok := common.GetJsonType(r.Input); ok {
|
||||
// inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
|
||||
// return inputs
|
||||
// }
|
||||
if common.GetJsonType(r.Input) == "string" {
|
||||
var str string
|
||||
_ = common.Unmarshal(r.Input, &str)
|
||||
inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
|
||||
return inputs
|
||||
}
|
||||
|
||||
// Try array of parts
|
||||
if array, ok := r.Input.([]any); ok {
|
||||
if common.GetJsonType(r.Input) == "array" {
|
||||
var array []any
|
||||
_ = common.Unmarshal(r.Input, &array)
|
||||
for _, itemAny := range array {
|
||||
// Already parsed MediaInput
|
||||
if media, ok := itemAny.(MediaInput); ok {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
package dto
|
||||
|
||||
type UpstreamDTO struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ID int `json:"id,omitempty"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
type UpstreamRequest struct {
|
||||
ChannelIDs []int64 `json:"channel_ids"`
|
||||
Upstreams []UpstreamDTO `json:"upstreams"`
|
||||
Timeout int `json:"timeout"`
|
||||
ChannelIDs []int64 `json:"channel_ids"`
|
||||
Upstreams []UpstreamDTO `json:"upstreams"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
// TestResult 上游测试连通性结果
|
||||
type TestResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DifferenceItem 差异项
|
||||
@@ -25,14 +25,14 @@ type TestResult struct {
|
||||
// Upstreams 为各渠道的上游值,具体数值 / "same" / nil
|
||||
|
||||
type DifferenceItem struct {
|
||||
Current interface{} `json:"current"`
|
||||
Upstreams map[string]interface{} `json:"upstreams"`
|
||||
Confidence map[string]bool `json:"confidence"`
|
||||
Current interface{} `json:"current"`
|
||||
Upstreams map[string]interface{} `json:"upstreams"`
|
||||
Confidence map[string]bool `json:"confidence"`
|
||||
}
|
||||
|
||||
type SyncableChannel struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ type UserSetting struct {
|
||||
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
|
||||
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
|
||||
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
|
||||
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
|
||||
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
|
||||
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
|
||||
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
|
||||
}
|
||||
|
||||
var (
|
||||
NotifyTypeEmail = "email" // Email 邮件
|
||||
NotifyTypeWebhook = "webhook" // Webhook
|
||||
NotifyTypeBark = "bark" // Bark 推送
|
||||
)
|
||||
|
||||
10
go.mod
10
go.mod
@@ -23,6 +23,7 @@ require (
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
@@ -44,11 +45,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.2.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
|
||||
github.com/antlabs/pcopy v0.1.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
|
||||
@@ -73,8 +70,6 @@ require (
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/sessions v1.2.1 // indirect
|
||||
github.com/huandu/xstrings v1.3.3 // indirect
|
||||
github.com/imdario/mergo v0.3.11 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.1 // indirect
|
||||
@@ -85,14 +80,11 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/copystructure v1.0.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/cast v1.3.1 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
|
||||
47
go.sum
47
go.sum
@@ -1,19 +1,11 @@
|
||||
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
|
||||
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
|
||||
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
|
||||
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/antlabs/pcopy v0.1.5 h1:5Fa1ExY9T6ar3ysAi4rzB5jiYg72Innm+/ESEIOSHvQ=
|
||||
github.com/antlabs/pcopy v0.1.5/go.mod h1:2FvdkPD3cFiM1CjGuXFCDQZqhKVcLI7IzeSJ2xUIOOI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
|
||||
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
|
||||
@@ -110,7 +102,6 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
@@ -121,10 +112,6 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
|
||||
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
|
||||
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -133,6 +120,8 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
@@ -163,12 +152,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
|
||||
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
|
||||
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -201,19 +186,14 @@ github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
|
||||
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
|
||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -251,36 +231,25 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -289,29 +258,18 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
@@ -326,7 +284,6 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
10
main.go
10
main.go
@@ -94,13 +94,9 @@ func main() {
|
||||
}
|
||||
go controller.AutomaticallyUpdateChannels(frequency)
|
||||
}
|
||||
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
|
||||
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
|
||||
if err != nil {
|
||||
common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error())
|
||||
}
|
||||
go controller.AutomaticallyTestChannels(frequency)
|
||||
}
|
||||
|
||||
go controller.AutomaticallyTestChannels()
|
||||
|
||||
if common.IsMasterNode && constant.UpdateTask {
|
||||
gopool.Go(func() {
|
||||
controller.UpdateMidjourneyTaskBulk()
|
||||
|
||||
12
middleware/disable-cache.go
Normal file
12
middleware/disable-cache.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func DisableCache() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
modelRequest.Model = modelName
|
||||
}
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
|
||||
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -208,7 +208,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
|
||||
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
|
||||
modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
|
||||
//modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
|
||||
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
modelRequest.Model = c.PostForm("model")
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
|
||||
relayMode := relayconstant.RelayModeAudioSpeech
|
||||
|
||||
@@ -18,12 +18,12 @@ func StatsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 增加活跃连接数
|
||||
atomic.AddInt64(&globalStats.activeConnections, 1)
|
||||
|
||||
|
||||
// 确保在请求结束时减少连接数
|
||||
defer func() {
|
||||
atomic.AddInt64(&globalStats.activeConnections, -1)
|
||||
}()
|
||||
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,4 @@ func GetStats() StatsInfo {
|
||||
return StatsInfo{
|
||||
ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,16 @@ type Channel struct {
|
||||
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
|
||||
AutoBan *int `json:"auto_ban" gorm:"default:1"`
|
||||
OtherInfo string `json:"other_info"`
|
||||
OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置
|
||||
Tag *string `json:"tag" gorm:"index"`
|
||||
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
|
||||
ParamOverride *string `json:"param_override" gorm:"type:text"`
|
||||
HeaderOverride *string `json:"header_override" gorm:"type:text"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
|
||||
// add after v0.8.5
|
||||
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
|
||||
|
||||
OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings
|
||||
|
||||
// cache info
|
||||
Keys []string `json:"-" gorm:"-"`
|
||||
}
|
||||
@@ -112,6 +114,10 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
|
||||
return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
|
||||
}
|
||||
|
||||
lock := GetChannelPollingLock(channel.Id)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
statusList := channel.ChannelInfo.MultiKeyStatusList
|
||||
// helper to get key status, default to enabled when missing
|
||||
getStatus := func(idx int) int {
|
||||
@@ -143,9 +149,6 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
|
||||
return keys[selectedIdx], selectedIdx, nil
|
||||
case constant.MultiKeyModePolling:
|
||||
// Use channel-specific lock to ensure thread-safe polling
|
||||
lock := GetChannelPollingLock(channel.Id)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
channelInfo, err := CacheGetChannelInfo(channel.Id)
|
||||
if err != nil {
|
||||
@@ -605,8 +608,12 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
||||
return false
|
||||
}
|
||||
if channelCache.ChannelInfo.IsMultiKey {
|
||||
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
|
||||
pollingLock := GetChannelPollingLock(channelId)
|
||||
pollingLock.Lock()
|
||||
// 如果是多Key模式,更新缓存中的状态
|
||||
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
|
||||
pollingLock.Unlock()
|
||||
//CacheUpdateChannel(channelCache)
|
||||
//return true
|
||||
} else {
|
||||
@@ -637,7 +644,11 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
||||
|
||||
if channel.ChannelInfo.IsMultiKey {
|
||||
beforeStatus := channel.Status
|
||||
// Protect map writes with the same per-channel lock used by readers
|
||||
pollingLock := GetChannelPollingLock(channelId)
|
||||
pollingLock.Lock()
|
||||
handlerMultiKeyUpdate(channel, usingKey, status, reason)
|
||||
pollingLock.Unlock()
|
||||
if beforeStatus != channel.Status {
|
||||
shouldUpdateAbilities = true
|
||||
}
|
||||
|
||||
@@ -64,22 +64,6 @@ var DB *gorm.DB
|
||||
|
||||
var LOG_DB *gorm.DB
|
||||
|
||||
// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors
|
||||
func dropIndexIfExists(tableName string, indexName string) {
|
||||
if !common.UsingMySQL {
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
// Check index existence via information_schema
|
||||
err := DB.Raw(
|
||||
"SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
|
||||
tableName, indexName,
|
||||
).Scan(&count).Error
|
||||
if err == nil && count > 0 {
|
||||
_ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error
|
||||
}
|
||||
}
|
||||
|
||||
func createRootAccountIfNeed() error {
|
||||
var user User
|
||||
//if user.Status != common.UserStatusEnabled {
|
||||
@@ -263,16 +247,6 @@ func InitLogDB() (err error) {
|
||||
}
|
||||
|
||||
func migrateDB() error {
|
||||
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
|
||||
// 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引 (model_name, deleted_at) 冲突
|
||||
dropIndexIfExists("models", "uk_model_name") // 新版复合索引名称(若已存在)
|
||||
dropIndexIfExists("models", "model_name") // 旧版列级唯一索引名称
|
||||
|
||||
dropIndexIfExists("vendors", "uk_vendor_name") // 新版复合索引名称(若已存在)
|
||||
dropIndexIfExists("vendors", "name") // 旧版列级唯一索引名称
|
||||
//if !common.UsingPostgreSQL {
|
||||
// return migrateDBFast()
|
||||
//}
|
||||
err := DB.AutoMigrate(
|
||||
&Channel{},
|
||||
&Token{},
|
||||
@@ -299,13 +273,6 @@ func migrateDB() error {
|
||||
}
|
||||
|
||||
func migrateDBFast() error {
|
||||
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
|
||||
// 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引冲突
|
||||
dropIndexIfExists("models", "uk_model_name")
|
||||
dropIndexIfExists("models", "model_name")
|
||||
|
||||
dropIndexIfExists("vendors", "uk_vendor_name")
|
||||
dropIndexIfExists("vendors", "name")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
@@ -20,17 +20,18 @@ type BoundChannel struct {
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Id int `json:"id"`
|
||||
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
||||
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
|
||||
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
||||
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"`
|
||||
Id int `json:"id"`
|
||||
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
||||
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
|
||||
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
||||
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
SyncOfficial int `json:"sync_official" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
|
||||
|
||||
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
|
||||
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
|
||||
|
||||
@@ -155,9 +155,12 @@ func updatePricing() {
|
||||
vendorMap[vendors[i].Id] = &vendors[i]
|
||||
}
|
||||
|
||||
// 初始化默认供应商映射
|
||||
initDefaultVendorMapping(metaMap, vendorMap, enableAbilities)
|
||||
|
||||
// 构建对前端友好的供应商列表
|
||||
vendorsList = make([]PricingVendor, 0, len(vendors))
|
||||
for _, v := range vendors {
|
||||
vendorsList = make([]PricingVendor, 0, len(vendorMap))
|
||||
for _, v := range vendorMap {
|
||||
vendorsList = append(vendorsList, PricingVendor{
|
||||
ID: v.Id,
|
||||
Name: v.Name,
|
||||
|
||||
128
model/pricing_default.go
Normal file
128
model/pricing_default.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 简化的供应商映射规则
|
||||
var defaultVendorRules = map[string]string{
|
||||
"gpt": "OpenAI",
|
||||
"dall-e": "OpenAI",
|
||||
"whisper": "OpenAI",
|
||||
"o1": "OpenAI",
|
||||
"o3": "OpenAI",
|
||||
"claude": "Anthropic",
|
||||
"gemini": "Google",
|
||||
"moonshot": "Moonshot",
|
||||
"kimi": "Moonshot",
|
||||
"chatglm": "智谱",
|
||||
"glm-": "智谱",
|
||||
"qwen": "阿里巴巴",
|
||||
"deepseek": "DeepSeek",
|
||||
"abab": "MiniMax",
|
||||
"ernie": "百度",
|
||||
"spark": "讯飞",
|
||||
"hunyuan": "腾讯",
|
||||
"command": "Cohere",
|
||||
"@cf/": "Cloudflare",
|
||||
"360": "360",
|
||||
"yi": "零一万物",
|
||||
"jina": "Jina",
|
||||
"mistral": "Mistral",
|
||||
"grok": "xAI",
|
||||
"llama": "Meta",
|
||||
"doubao": "字节跳动",
|
||||
"kling": "快手",
|
||||
"jimeng": "即梦",
|
||||
"vidu": "Vidu",
|
||||
}
|
||||
|
||||
// 供应商默认图标映射
|
||||
var defaultVendorIcons = map[string]string{
|
||||
"OpenAI": "OpenAI",
|
||||
"Anthropic": "Claude.Color",
|
||||
"Google": "Gemini.Color",
|
||||
"Moonshot": "Moonshot",
|
||||
"智谱": "Zhipu.Color",
|
||||
"阿里巴巴": "Qwen.Color",
|
||||
"DeepSeek": "DeepSeek.Color",
|
||||
"MiniMax": "Minimax.Color",
|
||||
"百度": "Wenxin.Color",
|
||||
"讯飞": "Spark.Color",
|
||||
"腾讯": "Hunyuan.Color",
|
||||
"Cohere": "Cohere.Color",
|
||||
"Cloudflare": "Cloudflare.Color",
|
||||
"360": "Ai360.Color",
|
||||
"零一万物": "Yi.Color",
|
||||
"Jina": "Jina",
|
||||
"Mistral": "Mistral.Color",
|
||||
"xAI": "XAI",
|
||||
"Meta": "Ollama",
|
||||
"字节跳动": "Doubao.Color",
|
||||
"快手": "Kling.Color",
|
||||
"即梦": "Jimeng.Color",
|
||||
"Vidu": "Vidu",
|
||||
"微软": "AzureAI",
|
||||
"Microsoft": "AzureAI",
|
||||
"Azure": "AzureAI",
|
||||
}
|
||||
|
||||
// initDefaultVendorMapping 简化的默认供应商映射
|
||||
func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) {
|
||||
for _, ability := range enableAbilities {
|
||||
modelName := ability.Model
|
||||
if _, exists := metaMap[modelName]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// 匹配供应商
|
||||
vendorID := 0
|
||||
modelLower := strings.ToLower(modelName)
|
||||
for pattern, vendorName := range defaultVendorRules {
|
||||
if strings.Contains(modelLower, pattern) {
|
||||
vendorID = getOrCreateVendor(vendorName, vendorMap)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 创建模型元数据
|
||||
metaMap[modelName] = &Model{
|
||||
ModelName: modelName,
|
||||
VendorID: vendorID,
|
||||
Status: 1,
|
||||
NameRule: NameRuleExact,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找或创建供应商
|
||||
func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int {
|
||||
// 查找现有供应商
|
||||
for id, vendor := range vendorMap {
|
||||
if vendor.Name == vendorName {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新供应商
|
||||
newVendor := &Vendor{
|
||||
Name: vendorName,
|
||||
Status: 1,
|
||||
Icon: getDefaultVendorIcon(vendorName),
|
||||
}
|
||||
|
||||
if err := newVendor.Insert(); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
vendorMap[newVendor.Id] = newVendor
|
||||
return newVendor.Id
|
||||
}
|
||||
|
||||
// 获取供应商默认图标
|
||||
func getDefaultVendorIcon(vendorName string) string {
|
||||
if icon, exists := defaultVendorIcons[vendorName]; exists {
|
||||
return icon
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -77,7 +77,7 @@ type SyncTaskQueryParams struct {
|
||||
UserIDs []int
|
||||
}
|
||||
|
||||
func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.TaskRelayInfo) *Task {
|
||||
func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task {
|
||||
t := &Task{
|
||||
UserId: relayInfo.UserId,
|
||||
SubmitTime: time.Now().Unix(),
|
||||
|
||||
@@ -16,7 +16,7 @@ type TwoFA struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
UserId int `json:"user_id" gorm:"unique;not null;index"`
|
||||
Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥,不返回给前端
|
||||
IsEnabled bool `json:"is_enabled" gorm:"default:false"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
FailedAttempts int `json:"failed_attempts" gorm:"default:0"`
|
||||
LockedUntil *time.Time `json:"locked_until,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
@@ -30,7 +30,7 @@ type TwoFABackupCode struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
UserId int `json:"user_id" gorm:"not null;index"`
|
||||
CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希
|
||||
IsUsed bool `json:"is_used" gorm:"default:false"`
|
||||
IsUsed bool `json:"is_used"`
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
@@ -91,6 +91,68 @@ func (user *User) SetSetting(setting dto.UserSetting) {
|
||||
user.Setting = string(settingBytes)
|
||||
}
|
||||
|
||||
// 根据用户角色生成默认的边栏配置
|
||||
func generateDefaultSidebarConfigForRole(userRole int) string {
|
||||
defaultConfig := map[string]interface{}{}
|
||||
|
||||
// 聊天区域 - 所有用户都可以访问
|
||||
defaultConfig["chat"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"playground": true,
|
||||
"chat": true,
|
||||
}
|
||||
|
||||
// 控制台区域 - 所有用户都可以访问
|
||||
defaultConfig["console"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"detail": true,
|
||||
"token": true,
|
||||
"log": true,
|
||||
"midjourney": true,
|
||||
"task": true,
|
||||
}
|
||||
|
||||
// 个人中心区域 - 所有用户都可以访问
|
||||
defaultConfig["personal"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"topup": true,
|
||||
"personal": true,
|
||||
}
|
||||
|
||||
// 管理员区域 - 根据角色决定
|
||||
if userRole == common.RoleAdminUser {
|
||||
// 管理员可以访问管理员区域,但不能访问系统设置
|
||||
defaultConfig["admin"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"channel": true,
|
||||
"models": true,
|
||||
"redemption": true,
|
||||
"user": true,
|
||||
"setting": false, // 管理员不能访问系统设置
|
||||
}
|
||||
} else if userRole == common.RoleRootUser {
|
||||
// 超级管理员可以访问所有功能
|
||||
defaultConfig["admin"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"channel": true,
|
||||
"models": true,
|
||||
"redemption": true,
|
||||
"user": true,
|
||||
"setting": true,
|
||||
}
|
||||
}
|
||||
// 普通用户不包含admin区域
|
||||
|
||||
// 转换为JSON字符串
|
||||
configBytes, err := json.Marshal(defaultConfig)
|
||||
if err != nil {
|
||||
common.SysLog("生成默认边栏配置失败: " + err.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(configBytes)
|
||||
}
|
||||
|
||||
// CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil
|
||||
func CheckUserExistOrDeleted(username string, email string) (bool, error) {
|
||||
var user User
|
||||
@@ -320,10 +382,34 @@ func (user *User) Insert(inviterId int) error {
|
||||
user.Quota = common.QuotaForNewUser
|
||||
//user.SetAccessToken(common.GetUUID())
|
||||
user.AffCode = common.GetRandomString(4)
|
||||
|
||||
// 初始化用户设置,包括默认的边栏配置
|
||||
if user.Setting == "" {
|
||||
defaultSetting := dto.UserSetting{}
|
||||
// 这里暂时不设置SidebarModules,因为需要在用户创建后根据角色设置
|
||||
user.SetSetting(defaultSetting)
|
||||
}
|
||||
|
||||
result := DB.Create(user)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// 用户创建成功后,根据角色初始化边栏配置
|
||||
// 需要重新获取用户以确保有正确的ID和Role
|
||||
var createdUser User
|
||||
if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil {
|
||||
// 生成基于角色的默认边栏配置
|
||||
defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role)
|
||||
if defaultSidebarConfig != "" {
|
||||
currentSetting := createdUser.GetSetting()
|
||||
currentSetting.SidebarModules = defaultSidebarConfig
|
||||
createdUser.SetSetting(currentSetting)
|
||||
createdUser.Update(false)
|
||||
common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role))
|
||||
}
|
||||
}
|
||||
|
||||
if common.QuotaForNewUser > 0 {
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
|
||||
type Vendor struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name_delete_at,priority:1"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name_delete_at,priority:2"`
|
||||
}
|
||||
|
||||
// Insert 创建新的供应商记录
|
||||
|
||||
@@ -53,7 +53,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
if resp != nil {
|
||||
httpResp = resp.(*http.Response)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
|
||||
@@ -30,16 +30,16 @@ type Adaptor interface {
|
||||
}
|
||||
|
||||
type TaskAdaptor interface {
|
||||
Init(info *relaycommon.TaskRelayInfo)
|
||||
Init(info *relaycommon.RelayInfo)
|
||||
|
||||
ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) *dto.TaskError
|
||||
ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError
|
||||
|
||||
BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error)
|
||||
BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error
|
||||
BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error)
|
||||
BuildRequestURL(info *relaycommon.RelayInfo) (string, error)
|
||||
BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error
|
||||
BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error)
|
||||
|
||||
DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error)
|
||||
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, err *dto.TaskError)
|
||||
DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error)
|
||||
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, err *dto.TaskError)
|
||||
|
||||
GetModelList() []string
|
||||
GetChannelName() string
|
||||
|
||||
@@ -3,7 +3,6 @@ package ali
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/dto"
|
||||
@@ -14,6 +13,8 @@ import (
|
||||
"one-api/relay/constant"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Adaptor struct {
|
||||
@@ -44,6 +45,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
|
||||
case constant.RelayModeImagesGenerations:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
|
||||
case constant.RelayModeImagesEdits:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
|
||||
case constant.RelayModeCompletions:
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
|
||||
default:
|
||||
@@ -66,6 +69,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
if info.RelayMode == constant.RelayModeImagesGenerations {
|
||||
req.Set("X-DashScope-Async", "enable")
|
||||
}
|
||||
if info.RelayMode == constant.RelayModeImagesEdits {
|
||||
req.Set("Content-Type", "application/json")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,11 +99,30 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
aliRequest, err := oaiImage2Ali(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert image request failed: %w", err)
|
||||
if info.RelayMode == constant.RelayModeImagesGenerations {
|
||||
aliRequest, err := oaiImage2Ali(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert image request failed: %w", err)
|
||||
}
|
||||
return aliRequest, nil
|
||||
} else if info.RelayMode == constant.RelayModeImagesEdits {
|
||||
// ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416
|
||||
// 如果用户使用表单,则需要解析表单数据
|
||||
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
aliRequest, err := oaiFormEdit2AliImageEdit(c, info, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert image edit form request failed: %w", err)
|
||||
}
|
||||
return aliRequest, nil
|
||||
} else {
|
||||
aliRequest, err := oaiImage2Ali(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert image request failed: %w", err)
|
||||
}
|
||||
return aliRequest, nil
|
||||
}
|
||||
}
|
||||
return aliRequest, nil
|
||||
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
@@ -134,6 +159,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeImagesGenerations:
|
||||
err, usage = aliImageHandler(c, resp, info)
|
||||
case constant.RelayModeImagesEdits:
|
||||
err, usage = aliImageEditHandler(c, resp, info)
|
||||
case constant.RelayModeRerank:
|
||||
err, usage = RerankHandler(c, resp, info)
|
||||
default:
|
||||
|
||||
@@ -3,10 +3,15 @@ package ali
|
||||
import "one-api/dto"
|
||||
|
||||
type AliMessage struct {
|
||||
Content string `json:"content"`
|
||||
Content any `json:"content"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type AliMediaContent struct {
|
||||
Image string `json:"image,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type AliInput struct {
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
//History []AliMessage `json:"history,omitempty"`
|
||||
@@ -70,13 +75,14 @@ type TaskResult struct {
|
||||
}
|
||||
|
||||
type AliOutput struct {
|
||||
TaskId string `json:"task_id,omitempty"`
|
||||
TaskStatus string `json:"task_status,omitempty"`
|
||||
Text string `json:"text"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Results []TaskResult `json:"results,omitempty"`
|
||||
TaskId string `json:"task_id,omitempty"`
|
||||
TaskStatus string `json:"task_status,omitempty"`
|
||||
Text string `json:"text"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Results []TaskResult `json:"results,omitempty"`
|
||||
Choices []map[string]any `json:"choices,omitempty"`
|
||||
}
|
||||
|
||||
type AliResponse struct {
|
||||
@@ -101,8 +107,9 @@ type AliImageParameters struct {
|
||||
}
|
||||
|
||||
type AliImageInput struct {
|
||||
Prompt string `json:"prompt"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Messages []AliMessage `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
type AliRerankParameters struct {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
@@ -21,7 +24,7 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
var imageRequest AliImageRequest
|
||||
imageRequest.Model = request.Model
|
||||
imageRequest.ResponseFormat = request.ResponseFormat
|
||||
|
||||
logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra)
|
||||
if request.Extra != nil {
|
||||
if val, ok := request.Extra["parameters"]; ok {
|
||||
err := common.Unmarshal(val, &imageRequest.Parameters)
|
||||
@@ -54,6 +57,100 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
return &imageRequest, nil
|
||||
}
|
||||
|
||||
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
var imageRequest AliImageRequest
|
||||
imageRequest.Model = request.Model
|
||||
imageRequest.ResponseFormat = request.ResponseFormat
|
||||
|
||||
mf := c.Request.MultipartForm
|
||||
if mf == nil {
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
|
||||
}
|
||||
mf = c.Request.MultipartForm
|
||||
}
|
||||
|
||||
var imageFiles []*multipart.FileHeader
|
||||
var exists bool
|
||||
|
||||
// First check for standard "image" field
|
||||
if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 {
|
||||
// If not found, check for "image[]" field
|
||||
if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 {
|
||||
// If still not found, iterate through all fields to find any that start with "image["
|
||||
foundArrayImages := false
|
||||
for fieldName, files := range mf.File {
|
||||
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
|
||||
foundArrayImages = true
|
||||
imageFiles = append(imageFiles, files...)
|
||||
}
|
||||
}
|
||||
|
||||
// If no image fields found at all
|
||||
if !foundArrayImages && (len(imageFiles) == 0) {
|
||||
return nil, errors.New("image is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(imageFiles) == 0 {
|
||||
return nil, errors.New("image is required")
|
||||
}
|
||||
|
||||
if len(imageFiles) > 1 {
|
||||
return nil, errors.New("only one image is supported for qwen edit")
|
||||
}
|
||||
|
||||
// 获取base64编码的图片
|
||||
var imageBase64s []string
|
||||
for _, file := range imageFiles {
|
||||
image, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to open image file")
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
imageData, err := io.ReadAll(image)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to read image file")
|
||||
}
|
||||
|
||||
// 获取MIME类型
|
||||
mimeType := http.DetectContentType(imageData)
|
||||
|
||||
// 编码为base64
|
||||
base64Data := base64.StdEncoding.EncodeToString(imageData)
|
||||
|
||||
// 构造data URL格式
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
imageBase64s = append(imageBase64s, dataURL)
|
||||
image.Close()
|
||||
}
|
||||
|
||||
//dto.MediaContent{}
|
||||
mediaContents := make([]AliMediaContent, len(imageBase64s))
|
||||
for i, b64 := range imageBase64s {
|
||||
mediaContents[i] = AliMediaContent{
|
||||
Image: b64,
|
||||
}
|
||||
}
|
||||
mediaContents = append(mediaContents, AliMediaContent{
|
||||
Text: request.Prompt,
|
||||
})
|
||||
imageRequest.Input = AliImageInput{
|
||||
Messages: []AliMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Content: mediaContents,
|
||||
},
|
||||
},
|
||||
}
|
||||
imageRequest.Parameters = AliImageParameters{
|
||||
Watermark: request.Watermark,
|
||||
}
|
||||
return &imageRequest, nil
|
||||
}
|
||||
|
||||
func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) {
|
||||
url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID)
|
||||
|
||||
@@ -196,8 +293,47 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
c.Writer.Write(jsonResponse)
|
||||
service.IOCopyBytesGracefully(c, resp, jsonResponse)
|
||||
return nil, &dto.Usage{}
|
||||
}
|
||||
|
||||
func aliImageEditHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
|
||||
var aliResponse AliResponse
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
err = common.Unmarshal(responseBody, &aliResponse)
|
||||
if err != nil {
|
||||
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
if aliResponse.Message != "" {
|
||||
logger.LogError(c, "ali_task_failed: "+aliResponse.Message)
|
||||
return types.NewError(errors.New(aliResponse.Message), types.ErrorCodeBadResponse), nil
|
||||
}
|
||||
var fullTextResponse dto.ImageResponse
|
||||
if len(aliResponse.Output.Choices) > 0 {
|
||||
fullTextResponse = dto.ImageResponse{
|
||||
Created: info.StartTime.Unix(),
|
||||
Data: []dto.ImageData{
|
||||
{
|
||||
Url: aliResponse.Output.Choices[0]["message"].(map[string]any)["content"].([]any)[0].(map[string]any)["image"].(string),
|
||||
B64Json: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var mapResponse map[string]any
|
||||
_ = common.Unmarshal(responseBody, &mapResponse)
|
||||
fullTextResponse.Extra = mapResponse
|
||||
jsonResponse, err := common.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
service.IOCopyBytesGracefully(c, resp, jsonResponse)
|
||||
return nil, &dto.Usage{}
|
||||
}
|
||||
|
||||
@@ -264,9 +264,8 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("resp is nil")
|
||||
@@ -277,7 +276,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
fullRequestURL, err := a.BuildRequestURL(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -294,7 +293,7 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.TaskRelayInfo,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setup request header failed: %w", err)
|
||||
}
|
||||
resp, err := doRequest(c, req, info.RelayInfo)
|
||||
resp, err := doRequest(c, req, info)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,16 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
// 检查是否为Nova模型
|
||||
if isNovaModel(request.Model) {
|
||||
novaReq := convertToNovaRequest(request)
|
||||
c.Set("request_model", request.Model)
|
||||
c.Set("converted_request", novaReq)
|
||||
c.Set("is_nova_model", true)
|
||||
return novaReq, nil
|
||||
}
|
||||
|
||||
// 原有的Claude模型处理逻辑
|
||||
var claudeReq *dto.ClaudeRequest
|
||||
var err error
|
||||
claudeReq, err = claude.RequestOpenAI2ClaudeMessage(c, *request)
|
||||
@@ -69,6 +78,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
c.Set("request_model", claudeReq.Model)
|
||||
c.Set("converted_request", claudeReq)
|
||||
c.Set("is_nova_model", false)
|
||||
return claudeReq, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package aws
|
||||
|
||||
import "strings"
|
||||
|
||||
var awsModelIDMap = map[string]string{
|
||||
"claude-instant-1.2": "anthropic.claude-instant-v1",
|
||||
"claude-2.0": "anthropic.claude-v2",
|
||||
@@ -14,6 +16,11 @@ var awsModelIDMap = map[string]string{
|
||||
"claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
|
||||
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
// Nova models
|
||||
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
|
||||
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
||||
"nova-pro-v1:0": "amazon.nova-pro-v1:0",
|
||||
"nova-premier-v1:0": "amazon.nova-premier-v1:0",
|
||||
}
|
||||
|
||||
var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
||||
@@ -58,7 +65,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0": {
|
||||
"us": true,
|
||||
},
|
||||
}
|
||||
// Nova models - all support three major regions
|
||||
"amazon.nova-micro-v1:0": {
|
||||
"us": true,
|
||||
"eu": true,
|
||||
"apac": true,
|
||||
},
|
||||
"amazon.nova-lite-v1:0": {
|
||||
"us": true,
|
||||
"eu": true,
|
||||
"apac": true,
|
||||
},
|
||||
"amazon.nova-pro-v1:0": {
|
||||
"us": true,
|
||||
"eu": true,
|
||||
"apac": true,
|
||||
},
|
||||
"amazon.nova-premier-v1:0": {
|
||||
"us": true,
|
||||
"eu": true,
|
||||
"apac": true,
|
||||
}}
|
||||
|
||||
var awsRegionCrossModelPrefixMap = map[string]string{
|
||||
"us": "us",
|
||||
@@ -67,3 +94,8 @@ var awsRegionCrossModelPrefixMap = map[string]string{
|
||||
}
|
||||
|
||||
var ChannelName = "aws"
|
||||
|
||||
// 判断是否为Nova模型
|
||||
func isNovaModel(modelId string) bool {
|
||||
return strings.HasPrefix(modelId, "nova-")
|
||||
}
|
||||
|
||||
@@ -34,3 +34,92 @@ func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest {
|
||||
Thinking: req.Thinking,
|
||||
}
|
||||
}
|
||||
|
||||
// NovaMessage Nova模型使用messages-v1格式
|
||||
type NovaMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content []NovaContent `json:"content"`
|
||||
}
|
||||
|
||||
type NovaContent struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type NovaRequest struct {
|
||||
SchemaVersion string `json:"schemaVersion"` // 请求版本,例如 "1.0"
|
||||
Messages []NovaMessage `json:"messages"` // 对话消息列表
|
||||
InferenceConfig *NovaInferenceConfig `json:"inferenceConfig,omitempty"` // 推理配置,可选
|
||||
}
|
||||
|
||||
type NovaInferenceConfig struct {
|
||||
MaxTokens int `json:"maxTokens,omitempty"` // 最大生成的 token 数
|
||||
Temperature float64 `json:"temperature,omitempty"` // 随机性 (默认 0.7, 范围 0-1)
|
||||
TopP float64 `json:"topP,omitempty"` // nucleus sampling (默认 0.9, 范围 0-1)
|
||||
TopK int `json:"topK,omitempty"` // 限制候选 token 数 (默认 50, 范围 0-128)
|
||||
StopSequences []string `json:"stopSequences,omitempty"` // 停止生成的序列
|
||||
}
|
||||
|
||||
// 转换OpenAI请求为Nova格式
|
||||
func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest {
|
||||
novaMessages := make([]NovaMessage, len(req.Messages))
|
||||
for i, msg := range req.Messages {
|
||||
novaMessages[i] = NovaMessage{
|
||||
Role: msg.Role,
|
||||
Content: []NovaContent{{Text: msg.StringContent()}},
|
||||
}
|
||||
}
|
||||
|
||||
novaReq := &NovaRequest{
|
||||
SchemaVersion: "messages-v1",
|
||||
Messages: novaMessages,
|
||||
}
|
||||
|
||||
// 设置推理配置
|
||||
if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 || req.TopK != 0 || req.Stop != nil {
|
||||
novaReq.InferenceConfig = &NovaInferenceConfig{}
|
||||
if req.MaxTokens != 0 {
|
||||
novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens)
|
||||
}
|
||||
if req.Temperature != nil && *req.Temperature != 0 {
|
||||
novaReq.InferenceConfig.Temperature = *req.Temperature
|
||||
}
|
||||
if req.TopP != 0 {
|
||||
novaReq.InferenceConfig.TopP = req.TopP
|
||||
}
|
||||
if req.TopK != 0 {
|
||||
novaReq.InferenceConfig.TopK = req.TopK
|
||||
}
|
||||
if req.Stop != nil {
|
||||
if stopSequences := parseStopSequences(req.Stop); len(stopSequences) > 0 {
|
||||
novaReq.InferenceConfig.StopSequences = stopSequences
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return novaReq
|
||||
}
|
||||
|
||||
// parseStopSequences 解析停止序列,支持字符串或字符串数组
|
||||
func parseStopSequences(stop any) []string {
|
||||
if stop == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := stop.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
return []string{v}
|
||||
}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
var sequences []string
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok && str != "" {
|
||||
sequences = append(sequences, str)
|
||||
}
|
||||
}
|
||||
return sequences
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package aws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -93,7 +94,19 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
|
||||
}
|
||||
|
||||
awsModelId := awsModelID(c.GetString("request_model"))
|
||||
// 检查是否为Nova模型
|
||||
isNova, _ := c.Get("is_nova_model")
|
||||
if isNova == true {
|
||||
// Nova模型也支持跨区域
|
||||
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
|
||||
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
|
||||
if canCrossRegion {
|
||||
awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix)
|
||||
}
|
||||
return handleNovaRequest(c, awsCli, info, awsModelId)
|
||||
}
|
||||
|
||||
// 原有的Claude处理逻辑
|
||||
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
|
||||
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
|
||||
if canCrossRegion {
|
||||
@@ -130,7 +143,12 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
|
||||
Usage: &dto.Usage{},
|
||||
}
|
||||
|
||||
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, awsResp.Body, RequestModeMessage)
|
||||
// 复制上游 Content-Type 到客户端响应头
|
||||
if awsResp.ContentType != nil && *awsResp.ContentType != "" {
|
||||
c.Writer.Header().Set("Content-Type", *awsResp.ContentType)
|
||||
}
|
||||
|
||||
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, nil, awsResp.Body, RequestModeMessage)
|
||||
if handlerErr != nil {
|
||||
return handlerErr, nil
|
||||
}
|
||||
@@ -204,3 +222,74 @@ func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
claude.HandleStreamFinalResponse(c, info, claudeInfo, RequestModeMessage)
|
||||
return nil, claudeInfo.Usage
|
||||
}
|
||||
|
||||
// Nova模型处理函数
|
||||
func handleNovaRequest(c *gin.Context, awsCli *bedrockruntime.Client, info *relaycommon.RelayInfo, awsModelId string) (*types.NewAPIError, *dto.Usage) {
|
||||
novaReq_, ok := c.Get("converted_request")
|
||||
if !ok {
|
||||
return types.NewError(errors.New("nova request not found"), types.ErrorCodeInvalidRequest), nil
|
||||
}
|
||||
novaReq := novaReq_.(*NovaRequest)
|
||||
|
||||
// 使用InvokeModel API,但使用Nova格式的请求体
|
||||
awsReq := &bedrockruntime.InvokeModelInput{
|
||||
ModelId: aws.String(awsModelId),
|
||||
Accept: aws.String("application/json"),
|
||||
ContentType: aws.String("application/json"),
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(novaReq)
|
||||
if err != nil {
|
||||
return types.NewError(errors.Wrap(err, "marshal nova request"), types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
awsReq.Body = reqBody
|
||||
|
||||
awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)
|
||||
if err != nil {
|
||||
return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil
|
||||
}
|
||||
|
||||
// 解析Nova响应
|
||||
var novaResp struct {
|
||||
Output struct {
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"output"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"inputTokens"`
|
||||
OutputTokens int `json:"outputTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(awsResp.Body, &novaResp); err != nil {
|
||||
return types.NewError(errors.Wrap(err, "unmarshal nova response"), types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
|
||||
// 构造OpenAI格式响应
|
||||
response := dto.OpenAITextResponse{
|
||||
Id: helper.GetResponseID(c),
|
||||
Object: "chat.completion",
|
||||
Created: common.GetTimestamp(),
|
||||
Model: info.UpstreamModelName,
|
||||
Choices: []dto.OpenAITextResponseChoice{{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: novaResp.Output.Message.Content[0].Text,
|
||||
},
|
||||
FinishReason: "stop",
|
||||
}},
|
||||
Usage: dto.Usage{
|
||||
PromptTokens: novaResp.Usage.InputTokens,
|
||||
CompletionTokens: novaResp.Usage.OutputTokens,
|
||||
TotalTokens: novaResp.Usage.TotalTokens,
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
return nil, &response.Usage
|
||||
}
|
||||
|
||||
@@ -81,20 +81,23 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if strings.HasSuffix(info.UpstreamModelName, "-search") {
|
||||
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search")
|
||||
request.Model = info.UpstreamModelName
|
||||
toMap := request.ToMap()
|
||||
toMap["web_search"] = map[string]any{
|
||||
"enable": true,
|
||||
"enable_citation": true,
|
||||
"enable_trace": true,
|
||||
"enable_status": false,
|
||||
if len(request.WebSearch) == 0 {
|
||||
toMap := request.ToMap()
|
||||
toMap["web_search"] = map[string]any{
|
||||
"enable": true,
|
||||
"enable_citation": true,
|
||||
"enable_trace": true,
|
||||
"enable_status": false,
|
||||
}
|
||||
return toMap, nil
|
||||
}
|
||||
return toMap, nil
|
||||
return request, nil
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
return nil, nil
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
|
||||
|
||||
@@ -32,7 +32,7 @@ func stopReasonClaude2OpenAI(reason string) string {
|
||||
case "end_turn":
|
||||
return "stop"
|
||||
case "max_tokens":
|
||||
return "max_tokens"
|
||||
return "length"
|
||||
case "tool_use":
|
||||
return "tool_calls"
|
||||
default:
|
||||
@@ -274,19 +274,28 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
|
||||
claudeMessages := make([]dto.ClaudeMessage, 0)
|
||||
isFirstMessage := true
|
||||
// 初始化system消息数组,用于累积多个system消息
|
||||
var systemMessages []dto.ClaudeMediaMessage
|
||||
|
||||
for _, message := range formatMessages {
|
||||
if message.Role == "system" {
|
||||
// 根据Claude API规范,system字段使用数组格式更有通用性
|
||||
if message.IsStringContent() {
|
||||
claudeRequest.System = message.StringContent()
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](message.StringContent()),
|
||||
})
|
||||
} else {
|
||||
contents := message.ParseContent()
|
||||
content := ""
|
||||
for _, ctx := range contents {
|
||||
// 支持复合内容的system消息(虽然不常见,但需要考虑完整性)
|
||||
for _, ctx := range message.ParseContent() {
|
||||
if ctx.Type == "text" {
|
||||
content += ctx.Text
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](ctx.Text),
|
||||
})
|
||||
}
|
||||
// 未来可以在这里扩展对图片等其他类型的支持
|
||||
}
|
||||
claudeRequest.System = content
|
||||
}
|
||||
} else {
|
||||
if isFirstMessage {
|
||||
@@ -392,6 +401,12 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
claudeMessages = append(claudeMessages, claudeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置累积的system消息
|
||||
if len(systemMessages) > 0 {
|
||||
claudeRequest.System = systemMessages
|
||||
}
|
||||
|
||||
claudeRequest.Prompt = ""
|
||||
claudeRequest.Messages = claudeMessages
|
||||
return &claudeRequest, nil
|
||||
@@ -426,7 +441,10 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
|
||||
choice.Delta.Role = "assistant"
|
||||
} else if claudeResponse.Type == "content_block_start" {
|
||||
if claudeResponse.ContentBlock != nil {
|
||||
//choice.Delta.SetContentString(claudeResponse.ContentBlock.Text)
|
||||
// 如果是文本块,尽可能发送首段文本(若存在)
|
||||
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
|
||||
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
|
||||
}
|
||||
if claudeResponse.ContentBlock.Type == "tool_use" {
|
||||
tools = append(tools, dto.ToolCallResponse{
|
||||
Index: common.GetPointer(fcIdx),
|
||||
@@ -698,7 +716,7 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
|
||||
return claudeInfo.Usage, nil
|
||||
}
|
||||
|
||||
func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data []byte, requestMode int) *types.NewAPIError {
|
||||
func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, httpResp *http.Response, data []byte, requestMode int) *types.NewAPIError {
|
||||
var claudeResponse dto.ClaudeResponse
|
||||
err := common.Unmarshal(data, &claudeResponse)
|
||||
if err != nil {
|
||||
@@ -736,7 +754,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
|
||||
}
|
||||
|
||||
service.IOCopyBytesGracefully(c, nil, responseData)
|
||||
service.IOCopyBytesGracefully(c, httpResp, responseData)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -757,7 +775,7 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
|
||||
if common.DebugEnabled {
|
||||
println("responseBody: ", string(responseBody))
|
||||
}
|
||||
handleErr := HandleClaudeResponseData(c, info, claudeInfo, responseBody, requestMode)
|
||||
handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody, requestMode)
|
||||
if handleErr != nil {
|
||||
return nil, handleErr
|
||||
}
|
||||
|
||||
@@ -46,6 +46,32 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
|
||||
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
|
||||
if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
|
||||
imageOutputCounts := 0
|
||||
for _, candidate := range geminiResponse.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image/") {
|
||||
imageOutputCounts++
|
||||
}
|
||||
}
|
||||
}
|
||||
if imageOutputCounts != 0 {
|
||||
usage.CompletionTokens = usage.CompletionTokens - imageOutputCounts*1290
|
||||
usage.TotalTokens = usage.TotalTokens - imageOutputCounts*1290
|
||||
c.Set("gemini_image_tokens", imageOutputCounts*1290)
|
||||
}
|
||||
}
|
||||
|
||||
// if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
|
||||
// for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
|
||||
// if detail.Modality == "IMAGE" {
|
||||
// usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
|
||||
// usage.TotalTokens = usage.TotalTokens - detail.TokenCount
|
||||
// c.Set("gemini_image_tokens", detail.TokenCount)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
@@ -136,6 +162,16 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
|
||||
for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
|
||||
if detail.Modality == "IMAGE" {
|
||||
usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
|
||||
usage.TotalTokens = usage.TotalTokens - detail.TokenCount
|
||||
c.Set("gemini_image_tokens", detail.TokenCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接发送 GeminiChatResponse 响应
|
||||
|
||||
@@ -749,7 +749,16 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
||||
var texts []string
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.FunctionCall != nil {
|
||||
if part.InlineData != nil {
|
||||
// 媒体内容
|
||||
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||
imgText := ""
|
||||
texts = append(texts, imgText)
|
||||
} else {
|
||||
// 其他媒体类型,直接显示链接
|
||||
texts = append(texts, fmt.Sprintf("[media](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data))
|
||||
}
|
||||
} else if part.FunctionCall != nil {
|
||||
choice.FinishReason = constant.FinishReasonToolCalls
|
||||
if call := getResponseToolCall(&part); call != nil {
|
||||
toolCalls = append(toolCalls, *call)
|
||||
|
||||
@@ -6,4 +6,4 @@ var ModelList = []string{
|
||||
"m3e-small",
|
||||
}
|
||||
|
||||
var ChannelName = "mokaai"
|
||||
var ChannelName = "mokaai"
|
||||
|
||||
@@ -68,9 +68,7 @@ func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*O
|
||||
StreamOptions: request.StreamOptions,
|
||||
Suffix: request.Suffix,
|
||||
}
|
||||
if think, ok := request.Extra["think"]; ok {
|
||||
ollamaRequest.Think = think
|
||||
}
|
||||
ollamaRequest.Think = request.Think
|
||||
return ollamaRequest, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -538,7 +538,13 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
|
||||
// 转换模型推理力度后缀
|
||||
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
|
||||
if effort != "" {
|
||||
request.Reasoning.Effort = effort
|
||||
if request.Reasoning == nil {
|
||||
request.Reasoning = &dto.Reasoning{
|
||||
Effort: effort,
|
||||
}
|
||||
} else {
|
||||
request.Reasoning.Effort = effort
|
||||
}
|
||||
request.Model = originModel
|
||||
}
|
||||
return request, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -280,11 +281,6 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
// count tokens by audio file duration
|
||||
audioTokens, err := countAudioTokens(c)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeCountTokenFailed), nil
|
||||
}
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
|
||||
@@ -292,6 +288,26 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
// 写入新的 response body
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
var responseData struct {
|
||||
Usage *dto.Usage `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil {
|
||||
if responseData.Usage.TotalTokens > 0 {
|
||||
usage := responseData.Usage
|
||||
if usage.PromptTokens == 0 {
|
||||
usage.PromptTokens = usage.InputTokens
|
||||
}
|
||||
if usage.CompletionTokens == 0 {
|
||||
usage.CompletionTokens = usage.OutputTokens
|
||||
}
|
||||
return nil, usage
|
||||
}
|
||||
}
|
||||
|
||||
audioTokens, err := countAudioTokens(c)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeCountTokenFailed), nil
|
||||
}
|
||||
usage := &dto.Usage{}
|
||||
usage.PromptTokens = audioTokens
|
||||
usage.CompletionTokens = 0
|
||||
|
||||
@@ -46,9 +46,17 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
|
||||
}
|
||||
}
|
||||
if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil {
|
||||
return &usage, nil
|
||||
}
|
||||
// 解析 Tools 用量
|
||||
for _, tool := range responsesResponse.Tools {
|
||||
info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++
|
||||
buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])]
|
||||
if !ok || buildToolinfo == nil {
|
||||
logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"]))
|
||||
continue
|
||||
}
|
||||
buildToolinfo.CallCount++
|
||||
}
|
||||
return &usage, nil
|
||||
}
|
||||
@@ -72,10 +80,16 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
sendResponsesStreamData(c, streamResponse, data)
|
||||
switch streamResponse.Type {
|
||||
case "response.completed":
|
||||
if streamResponse.Response.Usage != nil {
|
||||
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
|
||||
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
|
||||
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
|
||||
if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
|
||||
if streamResponse.Response.Usage.InputTokens != 0 {
|
||||
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
|
||||
}
|
||||
if streamResponse.Response.Usage.OutputTokens != 0 {
|
||||
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
|
||||
}
|
||||
if streamResponse.Response.Usage.TotalTokens != 0 {
|
||||
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
|
||||
}
|
||||
if streamResponse.Response.Usage.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
|
||||
}
|
||||
@@ -92,6 +106,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -107,10 +123,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
}
|
||||
|
||||
if usage.PromptTokens == 0 && usage.CompletionTokens != 0 {
|
||||
usage.PromptTokens = usage.CompletionTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
usage.PromptTokens = info.PromptTokens
|
||||
}
|
||||
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ type TaskAdaptor struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.ChannelBaseUrl
|
||||
|
||||
@@ -87,7 +87,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
}
|
||||
|
||||
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
||||
// Accept only POST /v1/video/generations as "generate" action.
|
||||
action := constant.TaskActionGenerate
|
||||
info.Action = action
|
||||
@@ -108,19 +108,19 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
|
||||
}
|
||||
|
||||
// BuildRequestURL constructs the upstream URL.
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
|
||||
}
|
||||
|
||||
// BuildRequestHeader sets required headers.
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return a.signRequest(req, a.accessKey, a.secretKey)
|
||||
}
|
||||
|
||||
// BuildRequestBody converts request into Jimeng specific format.
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
v, exists := c.Get("task_request")
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("request not found in context")
|
||||
@@ -139,12 +139,12 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
|
||||
}
|
||||
|
||||
// DoRequest delegates to common helper.
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
// DoResponse handles upstream response, returns taskID etc.
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
|
||||
@@ -4,13 +4,14 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/samber/lo"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/pkg/errors"
|
||||
@@ -37,15 +38,46 @@ type SubmitReq struct {
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type TrajectoryPoint struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
type DynamicMask struct {
|
||||
Mask string `json:"mask,omitempty"`
|
||||
Trajectories []TrajectoryPoint `json:"trajectories,omitempty"`
|
||||
}
|
||||
|
||||
type CameraConfig struct {
|
||||
Horizontal float64 `json:"horizontal,omitempty"`
|
||||
Vertical float64 `json:"vertical,omitempty"`
|
||||
Pan float64 `json:"pan,omitempty"`
|
||||
Tilt float64 `json:"tilt,omitempty"`
|
||||
Roll float64 `json:"roll,omitempty"`
|
||||
Zoom float64 `json:"zoom,omitempty"`
|
||||
}
|
||||
|
||||
type CameraControl struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Config *CameraConfig `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type requestPayload struct {
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
ModelName string `json:"model_name,omitempty"`
|
||||
Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model"
|
||||
CfgScale float64 `json:"cfg_scale,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
ImageTail string `json:"image_tail,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
ModelName string `json:"model_name,omitempty"`
|
||||
Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model"
|
||||
CfgScale float64 `json:"cfg_scale,omitempty"`
|
||||
StaticMask string `json:"static_mask,omitempty"`
|
||||
DynamicMasks []DynamicMask `json:"dynamic_masks,omitempty"`
|
||||
CameraControl *CameraControl `json:"camera_control,omitempty"`
|
||||
CallbackUrl string `json:"callback_url,omitempty"`
|
||||
ExternalTaskId string `json:"external_task_id,omitempty"`
|
||||
}
|
||||
|
||||
type responsePayload struct {
|
||||
@@ -79,7 +111,7 @@ type TaskAdaptor struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.ChannelBaseUrl
|
||||
a.apiKey = info.ApiKey
|
||||
@@ -88,7 +120,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
}
|
||||
|
||||
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
||||
// Accept only POST /v1/video/generations as "generate" action.
|
||||
action := constant.TaskActionGenerate
|
||||
info.Action = action
|
||||
@@ -109,13 +141,13 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
|
||||
}
|
||||
|
||||
// BuildRequestURL constructs the upstream URL.
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
|
||||
return fmt.Sprintf("%s%s", a.baseURL, path), nil
|
||||
}
|
||||
|
||||
// BuildRequestHeader sets required headers.
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||
token, err := a.createJWTToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create JWT token: %w", err)
|
||||
@@ -129,7 +161,7 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info
|
||||
}
|
||||
|
||||
// BuildRequestBody converts request into Kling specific format.
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
v, exists := c.Get("task_request")
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("request not found in context")
|
||||
@@ -140,6 +172,9 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.Image == "" && body.ImageTail == "" {
|
||||
c.Set("action", constant.TaskActionTextGenerate)
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -148,7 +183,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
|
||||
}
|
||||
|
||||
// DoRequest delegates to common helper.
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
if action := c.GetString("action"); action != "" {
|
||||
info.Action = action
|
||||
}
|
||||
@@ -156,7 +191,7 @@ func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo,
|
||||
}
|
||||
|
||||
// DoResponse handles upstream response, returns taskID etc.
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
@@ -222,14 +257,19 @@ func (a *TaskAdaptor) GetChannelName() string {
|
||||
|
||||
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) {
|
||||
r := requestPayload{
|
||||
Prompt: req.Prompt,
|
||||
Image: req.Image,
|
||||
Mode: defaultString(req.Mode, "std"),
|
||||
Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
|
||||
AspectRatio: a.getAspectRatio(req.Size),
|
||||
ModelName: req.Model,
|
||||
Model: req.Model, // Keep consistent with model_name, double writing improves compatibility
|
||||
CfgScale: 0.5,
|
||||
Prompt: req.Prompt,
|
||||
Image: req.Image,
|
||||
Mode: defaultString(req.Mode, "std"),
|
||||
Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
|
||||
AspectRatio: a.getAspectRatio(req.Size),
|
||||
ModelName: req.Model,
|
||||
Model: req.Model, // Keep consistent with model_name, double writing improves compatibility
|
||||
CfgScale: 0.5,
|
||||
StaticMask: "",
|
||||
DynamicMasks: []DynamicMask{},
|
||||
CameraControl: nil,
|
||||
CallbackUrl: "",
|
||||
ExternalTaskId: "",
|
||||
}
|
||||
if r.ModelName == "" {
|
||||
r.ModelName = "kling-v1"
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -16,6 +15,8 @@ import (
|
||||
"one-api/service"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TaskAdaptor struct {
|
||||
@@ -26,11 +27,11 @@ func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) {
|
||||
return nil, fmt.Errorf("not implement") // todo implement this method if needed
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
||||
action := strings.ToUpper(c.Param("action"))
|
||||
|
||||
var sunoRequest *dto.SunoSubmitReq
|
||||
@@ -58,20 +59,20 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
baseURL := info.ChannelBaseUrl
|
||||
fullRequestURL := fmt.Sprintf("%s%s", baseURL, "/suno/submit/"+info.Action)
|
||||
return fullRequestURL, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||
req.Header.Set("Accept", c.Request.Header.Get("Accept"))
|
||||
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
sunoRequest, ok := c.Get("task_request")
|
||||
if !ok {
|
||||
err := common.UnmarshalBodyReusable(c, &sunoRequest)
|
||||
@@ -86,11 +87,11 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRel
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
|
||||
@@ -84,12 +84,12 @@ type TaskAdaptor struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.ChannelBaseUrl
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) *dto.TaskError {
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
|
||||
var req SubmitReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return service.TaskErrorWrapper(err, "invalid_request_body", http.StatusBadRequest)
|
||||
@@ -109,7 +109,7 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) {
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
v, exists := c.Get("task_request")
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("request not found in context")
|
||||
@@ -132,7 +132,7 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayI
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
var path string
|
||||
switch info.Action {
|
||||
case constant.TaskActionGenerate:
|
||||
@@ -143,21 +143,21 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string,
|
||||
return fmt.Sprintf("%s/ent/v2%s", a.baseURL, path), nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Token "+info.ApiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
if action := c.GetString("action"); action != "" {
|
||||
info.Action = action
|
||||
}
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/relay/channel"
|
||||
"one-api/relay/channel/claude"
|
||||
@@ -80,16 +81,64 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
adc := &Credentials{}
|
||||
if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
|
||||
return "", fmt.Errorf("failed to decode credentials file: %w", err)
|
||||
}
|
||||
func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) {
|
||||
region := GetModelRegion(info.ApiVersion, info.OriginModelName)
|
||||
a.AccountCredentials = *adc
|
||||
if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey {
|
||||
adc := &Credentials{}
|
||||
if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil {
|
||||
return "", fmt.Errorf("failed to decode credentials file: %w", err)
|
||||
}
|
||||
a.AccountCredentials = *adc
|
||||
|
||||
if a.RequestMode == RequestModeLlama {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
|
||||
region,
|
||||
adc.ProjectID,
|
||||
region,
|
||||
), nil
|
||||
}
|
||||
|
||||
if region == "global" {
|
||||
return fmt.Sprintf(
|
||||
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
|
||||
adc.ProjectID,
|
||||
modelName,
|
||||
suffix,
|
||||
), nil
|
||||
} else {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
|
||||
region,
|
||||
adc.ProjectID,
|
||||
region,
|
||||
modelName,
|
||||
suffix,
|
||||
), nil
|
||||
}
|
||||
} else {
|
||||
if region == "global" {
|
||||
return fmt.Sprintf(
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
|
||||
modelName,
|
||||
suffix,
|
||||
info.ApiKey,
|
||||
), nil
|
||||
} else {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
|
||||
region,
|
||||
modelName,
|
||||
suffix,
|
||||
info.ApiKey,
|
||||
), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
suffix := ""
|
||||
if a.RequestMode == RequestModeGemini {
|
||||
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||
// 新增逻辑:处理 -thinking-<budget> 格式
|
||||
if strings.Contains(info.UpstreamModelName, "-thinking-") {
|
||||
@@ -112,23 +161,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
suffix = "predict"
|
||||
}
|
||||
|
||||
if region == "global" {
|
||||
return fmt.Sprintf(
|
||||
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
|
||||
adc.ProjectID,
|
||||
info.UpstreamModelName,
|
||||
suffix,
|
||||
), nil
|
||||
} else {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
|
||||
region,
|
||||
adc.ProjectID,
|
||||
region,
|
||||
info.UpstreamModelName,
|
||||
suffix,
|
||||
), nil
|
||||
}
|
||||
return a.getRequestUrl(info, info.UpstreamModelName, suffix)
|
||||
} else if a.RequestMode == RequestModeClaude {
|
||||
if info.IsStream {
|
||||
suffix = "streamRawPredict?alt=sse"
|
||||
@@ -139,41 +172,22 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
|
||||
model = v
|
||||
}
|
||||
if region == "global" {
|
||||
return fmt.Sprintf(
|
||||
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
|
||||
adc.ProjectID,
|
||||
model,
|
||||
suffix,
|
||||
), nil
|
||||
} else {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
|
||||
region,
|
||||
adc.ProjectID,
|
||||
region,
|
||||
model,
|
||||
suffix,
|
||||
), nil
|
||||
}
|
||||
return a.getRequestUrl(info, model, suffix)
|
||||
} else if a.RequestMode == RequestModeLlama {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
|
||||
region,
|
||||
adc.ProjectID,
|
||||
region,
|
||||
), nil
|
||||
return a.getRequestUrl(info, "", "")
|
||||
}
|
||||
return "", errors.New("unsupported request mode")
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
channel.SetupApiRequestHeader(info, c, req)
|
||||
accessToken, err := getAccessToken(a, info)
|
||||
if err != nil {
|
||||
return err
|
||||
if info.ChannelOtherSettings.VertexKeyType == "json" {
|
||||
accessToken, err := getAccessToken(a, info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Set("Authorization", "Bearer "+accessToken)
|
||||
}
|
||||
req.Set("Authorization", "Bearer "+accessToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package volcengine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -214,6 +215,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
// 适配 方舟deepseek混合模型 的 thinking 后缀
|
||||
if strings.HasSuffix(info.UpstreamModelName, "-thinking") && strings.HasPrefix(info.UpstreamModelName, "deepseek") {
|
||||
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
|
||||
request.Model = info.UpstreamModelName
|
||||
request.THINKING = json.RawMessage(`{"type": "enabled"}`)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
httpResp = resp.(*http.Response)
|
||||
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -151,7 +153,9 @@ func checkConditions(jsonStr string, conditions []ConditionOperation, logic stri
|
||||
}
|
||||
|
||||
func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, error) {
|
||||
value := gjson.Get(jsonStr, condition.Path)
|
||||
// 处理负数索引
|
||||
path := processNegativeIndex(jsonStr, condition.Path)
|
||||
value := gjson.Get(jsonStr, path)
|
||||
if !value.Exists() {
|
||||
if condition.PassMissingKey {
|
||||
return true, nil
|
||||
@@ -177,6 +181,37 @@ func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, e
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func processNegativeIndex(jsonStr string, path string) string {
|
||||
re := regexp.MustCompile(`\.(-\d+)`)
|
||||
matches := re.FindAllStringSubmatch(path, -1)
|
||||
|
||||
if len(matches) == 0 {
|
||||
return path
|
||||
}
|
||||
|
||||
result := path
|
||||
for _, match := range matches {
|
||||
negIndex := match[1]
|
||||
index, _ := strconv.Atoi(negIndex)
|
||||
|
||||
arrayPath := strings.Split(path, negIndex)[0]
|
||||
if strings.HasSuffix(arrayPath, ".") {
|
||||
arrayPath = arrayPath[:len(arrayPath)-1]
|
||||
}
|
||||
|
||||
array := gjson.Get(jsonStr, arrayPath)
|
||||
if array.IsArray() {
|
||||
length := len(array.Array())
|
||||
actualIndex := length + index
|
||||
if actualIndex >= 0 && actualIndex < length {
|
||||
result = strings.Replace(result, match[0], "."+strconv.Itoa(actualIndex), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// compareGjsonValues 直接比较两个gjson.Result,支持所有比较模式
|
||||
func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) {
|
||||
switch mode {
|
||||
@@ -274,21 +309,25 @@ func applyOperations(jsonStr string, operations []ParamOperation) (string, error
|
||||
if !ok {
|
||||
continue // 条件不满足,跳过当前操作
|
||||
}
|
||||
// 处理路径中的负数索引
|
||||
opPath := processNegativeIndex(result, op.Path)
|
||||
opFrom := processNegativeIndex(result, op.From)
|
||||
opTo := processNegativeIndex(result, op.To)
|
||||
|
||||
switch op.Mode {
|
||||
case "delete":
|
||||
result, err = sjson.Delete(result, op.Path)
|
||||
result, err = sjson.Delete(result, opPath)
|
||||
case "set":
|
||||
if op.KeepOrigin && gjson.Get(result, op.Path).Exists() {
|
||||
if op.KeepOrigin && gjson.Get(result, opPath).Exists() {
|
||||
continue
|
||||
}
|
||||
result, err = sjson.Set(result, op.Path, op.Value)
|
||||
result, err = sjson.Set(result, opPath, op.Value)
|
||||
case "move":
|
||||
result, err = moveValue(result, op.From, op.To)
|
||||
result, err = moveValue(result, opFrom, opTo)
|
||||
case "prepend":
|
||||
result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, true)
|
||||
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true)
|
||||
case "append":
|
||||
result, err = modifyValue(result, op.Path, op.Value, op.KeepOrigin, false)
|
||||
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown operation: %s", op.Mode)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ type RelayInfo struct {
|
||||
*RerankerInfo
|
||||
*ResponsesUsageInfo
|
||||
*ChannelMeta
|
||||
*TaskRelayInfo
|
||||
}
|
||||
|
||||
func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
|
||||
@@ -313,7 +314,7 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest)
|
||||
BuiltInTools: make(map[string]*BuildInToolInfo),
|
||||
}
|
||||
if len(request.Tools) > 0 {
|
||||
for _, tool := range request.Tools {
|
||||
for _, tool := range request.GetToolsMap() {
|
||||
toolType := common.Interface2String(tool["type"])
|
||||
info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{
|
||||
ToolName: toolType,
|
||||
@@ -400,6 +401,10 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
||||
},
|
||||
}
|
||||
|
||||
if info.RelayMode == relayconstant.RelayModeUnknown {
|
||||
info.RelayMode = c.GetInt("relay_mode")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/pg") {
|
||||
info.IsPlayground = true
|
||||
info.RequestURLPath = strings.TrimPrefix(info.RequestURLPath, "/pg")
|
||||
@@ -465,25 +470,12 @@ func (info *RelayInfo) HasSendResponse() bool {
|
||||
}
|
||||
|
||||
type TaskRelayInfo struct {
|
||||
*RelayInfo
|
||||
Action string
|
||||
OriginTaskID string
|
||||
|
||||
ConsumeQuota bool
|
||||
}
|
||||
|
||||
func GenTaskRelayInfo(c *gin.Context) (*TaskRelayInfo, error) {
|
||||
relayInfo, err := GenRelayInfo(c, types.RelayFormatTask, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := &TaskRelayInfo{
|
||||
RelayInfo: relayInfo,
|
||||
}
|
||||
info.InitChannelMeta(c)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
type TaskSubmitReq struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
@@ -2,12 +2,10 @@ package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"one-api/constant"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
|
||||
|
||||
@@ -130,7 +130,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
|
||||
jsonData, err := common.Marshal(convertedRequest)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
// apply param override
|
||||
@@ -158,7 +158,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
httpResp = resp.(*http.Response)
|
||||
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newApiErr := service.RelayErrorHandler(httpResp, false)
|
||||
newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newApiErr, statusCodeMappingStr)
|
||||
return newApiErr
|
||||
@@ -195,6 +195,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
imageTokens := usage.PromptTokensDetails.ImageTokens
|
||||
audioTokens := usage.PromptTokensDetails.AudioTokens
|
||||
completionTokens := usage.CompletionTokens
|
||||
cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
|
||||
|
||||
modelName := relayInfo.OriginModelName
|
||||
|
||||
tokenName := ctx.GetString("token_name")
|
||||
@@ -204,6 +206,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
modelRatio := relayInfo.PriceData.ModelRatio
|
||||
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
|
||||
modelPrice := relayInfo.PriceData.ModelPrice
|
||||
cachedCreationRatio := relayInfo.PriceData.CacheCreationRatio
|
||||
|
||||
// Convert values to decimal for precise calculation
|
||||
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
|
||||
@@ -211,12 +214,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
dImageTokens := decimal.NewFromInt(int64(imageTokens))
|
||||
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
|
||||
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
|
||||
dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens))
|
||||
dCompletionRatio := decimal.NewFromFloat(completionRatio)
|
||||
dCacheRatio := decimal.NewFromFloat(cacheRatio)
|
||||
dImageRatio := decimal.NewFromFloat(imageRatio)
|
||||
dModelRatio := decimal.NewFromFloat(modelRatio)
|
||||
dGroupRatio := decimal.NewFromFloat(groupRatio)
|
||||
dModelPrice := decimal.NewFromFloat(modelPrice)
|
||||
dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio)
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
|
||||
ratio := dModelRatio.Mul(dGroupRatio)
|
||||
@@ -284,6 +289,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
baseTokens = baseTokens.Sub(dCacheTokens)
|
||||
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
|
||||
}
|
||||
var dCachedCreationTokensWithRatio decimal.Decimal
|
||||
if !dCachedCreationTokens.IsZero() {
|
||||
baseTokens = baseTokens.Sub(dCachedCreationTokens)
|
||||
dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio)
|
||||
}
|
||||
|
||||
// 减去 image tokens
|
||||
var imageTokensWithRatio decimal.Decimal
|
||||
@@ -302,7 +312,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
|
||||
}
|
||||
}
|
||||
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
|
||||
promptQuota := baseTokens.Add(cachedTokensWithRatio).
|
||||
Add(imageTokensWithRatio).
|
||||
Add(dCachedCreationTokensWithRatio)
|
||||
|
||||
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
||||
|
||||
@@ -314,11 +326,22 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
} else {
|
||||
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
|
||||
}
|
||||
var dGeminiImageOutputQuota decimal.Decimal
|
||||
var imageOutputPrice float64
|
||||
if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
|
||||
imageOutputPrice = operation_setting.GetGeminiImageOutputPricePerMillionTokens(modelName)
|
||||
if imageOutputPrice > 0 {
|
||||
dImageOutputTokens := decimal.NewFromInt(int64(ctx.GetInt("gemini_image_tokens")))
|
||||
dGeminiImageOutputQuota = decimal.NewFromFloat(imageOutputPrice).Div(decimal.NewFromInt(1000000)).Mul(dImageOutputTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
}
|
||||
}
|
||||
// 添加 responses tools call 调用的配额
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
||||
// 添加 audio input 独立计费
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
||||
// 添加 Gemini image output 计费
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dGeminiImageOutputQuota)
|
||||
|
||||
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
||||
totalTokens := promptTokens + completionTokens
|
||||
@@ -384,6 +407,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
other["image_ratio"] = imageRatio
|
||||
other["image_output"] = imageTokens
|
||||
}
|
||||
if cachedCreationTokens != 0 {
|
||||
other["cache_creation_tokens"] = cachedCreationTokens
|
||||
other["cache_creation_ratio"] = cachedCreationRatio
|
||||
}
|
||||
if !dWebSearchQuota.IsZero() {
|
||||
if relayInfo.ResponsesUsageInfo != nil {
|
||||
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists {
|
||||
@@ -413,6 +440,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
other["audio_input_token_count"] = audioTokens
|
||||
other["audio_input_price"] = audioInputPrice
|
||||
}
|
||||
if !dGeminiImageOutputQuota.IsZero() {
|
||||
other["image_output_token_count"] = ctx.GetInt("gemini_image_tokens")
|
||||
other["image_output_price"] = imageOutputPrice
|
||||
}
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||
ChannelId: relayInfo.ChannelId,
|
||||
PromptTokens: promptTokens,
|
||||
|
||||
@@ -58,7 +58,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
if resp != nil {
|
||||
httpResp = resp.(*http.Response)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
|
||||
@@ -152,7 +152,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
httpResp = resp.(*http.Response)
|
||||
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
@@ -249,7 +249,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
|
||||
if resp != nil {
|
||||
httpResp = resp.(*http.Response)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -42,7 +41,7 @@ func SetEventStreamHeaders(c *gin.Context) {
|
||||
}
|
||||
|
||||
func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error {
|
||||
jsonData, err := json.Marshal(resp)
|
||||
jsonData, err := common.Marshal(resp)
|
||||
if err != nil {
|
||||
common.SysError("error marshalling stream response: " + err.Error())
|
||||
} else {
|
||||
@@ -104,7 +103,7 @@ func WssString(c *gin.Context, ws *websocket.Conn, str string) error {
|
||||
}
|
||||
|
||||
func WssObject(c *gin.Context, ws *websocket.Conn, object interface{}) error {
|
||||
jsonData, err := json.Marshal(object)
|
||||
jsonData, err := common.Marshal(object)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling object: %w", err)
|
||||
}
|
||||
|
||||
@@ -132,30 +132,34 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
|
||||
|
||||
switch relayMode {
|
||||
case relayconstant.RelayModeImagesEdits:
|
||||
_, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
|
||||
}
|
||||
formData := c.Request.PostForm
|
||||
imageRequest.Prompt = formData.Get("prompt")
|
||||
imageRequest.Model = formData.Get("model")
|
||||
imageRequest.N = uint(common.String2Int(formData.Get("n")))
|
||||
imageRequest.Quality = formData.Get("quality")
|
||||
imageRequest.Size = formData.Get("size")
|
||||
|
||||
if imageRequest.Model == "gpt-image-1" {
|
||||
if imageRequest.Quality == "" {
|
||||
imageRequest.Quality = "standard"
|
||||
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
_, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
|
||||
}
|
||||
}
|
||||
if imageRequest.N == 0 {
|
||||
imageRequest.N = 1
|
||||
}
|
||||
formData := c.Request.PostForm
|
||||
imageRequest.Prompt = formData.Get("prompt")
|
||||
imageRequest.Model = formData.Get("model")
|
||||
imageRequest.N = uint(common.String2Int(formData.Get("n")))
|
||||
imageRequest.Quality = formData.Get("quality")
|
||||
imageRequest.Size = formData.Get("size")
|
||||
|
||||
watermark := formData.Has("watermark")
|
||||
if watermark {
|
||||
imageRequest.Watermark = &watermark
|
||||
if imageRequest.Model == "gpt-image-1" {
|
||||
if imageRequest.Quality == "" {
|
||||
imageRequest.Quality = "standard"
|
||||
}
|
||||
}
|
||||
if imageRequest.N == 0 {
|
||||
imageRequest.N = 1
|
||||
}
|
||||
|
||||
watermark := formData.Has("watermark")
|
||||
if watermark {
|
||||
imageRequest.Watermark = &watermark
|
||||
}
|
||||
break
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
err := common.UnmarshalBodyReusable(c, imageRequest)
|
||||
if err != nil {
|
||||
@@ -163,7 +167,8 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
|
||||
}
|
||||
|
||||
if imageRequest.Model == "" {
|
||||
imageRequest.Model = "dall-e-3"
|
||||
//imageRequest.Model = "dall-e-3"
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
|
||||
if strings.Contains(imageRequest.Size, "×") {
|
||||
@@ -194,9 +199,9 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
|
||||
}
|
||||
}
|
||||
|
||||
if imageRequest.Prompt == "" {
|
||||
return nil, errors.New("prompt is required")
|
||||
}
|
||||
//if imageRequest.Prompt == "" {
|
||||
// return nil, errors.New("prompt is required")
|
||||
//}
|
||||
|
||||
if imageRequest.N == 0 {
|
||||
imageRequest.N = 1
|
||||
|
||||
@@ -2,14 +2,13 @@ package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
relaycommon "one-api/relay/common"
|
||||
relayconstant "one-api/relay/constant"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/setting/model_setting"
|
||||
@@ -56,10 +55,12 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed)
|
||||
}
|
||||
if info.RelayMode == relayconstant.RelayModeImagesEdits {
|
||||
|
||||
switch convertedRequest.(type) {
|
||||
case *bytes.Buffer:
|
||||
requestBody = convertedRequest.(io.Reader)
|
||||
} else {
|
||||
jsonData, err := json.Marshal(convertedRequest)
|
||||
default:
|
||||
jsonData, err := common.Marshal(convertedRequest)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
@@ -73,7 +74,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("image request body: %s", string(jsonData)))
|
||||
logger.LogDebug(c, fmt.Sprintf("image request body: %s", string(jsonData)))
|
||||
}
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
@@ -90,7 +91,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
httpResp = resp.(*http.Response)
|
||||
info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream")
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
@@ -119,7 +120,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
var logContent string
|
||||
|
||||
if len(request.Size) > 0 {
|
||||
logContent = fmt.Sprintf("大小 %s, 品质 %s", request.Size, quality)
|
||||
logContent = fmt.Sprintf("大小 %s, 品质 %s, 张数 %d", request.Size, quality, request.N)
|
||||
}
|
||||
|
||||
postConsumeQuota(c, info, usage.(*dto.Usage), logContent)
|
||||
|
||||
@@ -22,31 +22,31 @@ import (
|
||||
/*
|
||||
Task 任务通过平台、Action 区分任务
|
||||
*/
|
||||
func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
||||
info.InitChannelMeta(c)
|
||||
// ensure TaskRelayInfo is initialized to avoid nil dereference when accessing embedded fields
|
||||
if info.TaskRelayInfo == nil {
|
||||
info.TaskRelayInfo = &relaycommon.TaskRelayInfo{}
|
||||
}
|
||||
platform := constant.TaskPlatform(c.GetString("platform"))
|
||||
if platform == "" {
|
||||
platform = GetTaskPlatform(c)
|
||||
}
|
||||
|
||||
relayInfo, err := relaycommon.GenTaskRelayInfo(c)
|
||||
if err != nil {
|
||||
return service.TaskErrorWrapper(err, "gen_relay_info_failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
adaptor := GetTaskAdaptor(platform)
|
||||
if adaptor == nil {
|
||||
return service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest)
|
||||
}
|
||||
adaptor.Init(relayInfo)
|
||||
adaptor.Init(info)
|
||||
// get & validate taskRequest 获取并验证文本请求
|
||||
taskErr = adaptor.ValidateRequestAndSetAction(c, relayInfo)
|
||||
taskErr = adaptor.ValidateRequestAndSetAction(c, info)
|
||||
if taskErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
modelName := relayInfo.OriginModelName
|
||||
modelName := info.OriginModelName
|
||||
if modelName == "" {
|
||||
modelName = service.CoverTaskActionToModelName(platform, relayInfo.Action)
|
||||
modelName = service.CoverTaskActionToModelName(platform, info.Action)
|
||||
}
|
||||
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
|
||||
if !success {
|
||||
@@ -59,15 +59,15 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
}
|
||||
|
||||
// 预扣
|
||||
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
|
||||
groupRatio := ratio_setting.GetGroupRatio(info.UsingGroup)
|
||||
var ratio float64
|
||||
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
|
||||
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(info.UserGroup, info.UsingGroup)
|
||||
if hasUserGroupRatio {
|
||||
ratio = modelPrice * userGroupRatio
|
||||
} else {
|
||||
ratio = modelPrice * groupRatio
|
||||
}
|
||||
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
|
||||
userQuota, err := model.GetUserQuota(info.UserId, false)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -78,8 +78,8 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
return
|
||||
}
|
||||
|
||||
if relayInfo.OriginTaskID != "" {
|
||||
originTask, exist, err := model.GetByTaskId(relayInfo.UserId, relayInfo.OriginTaskID)
|
||||
if info.OriginTaskID != "" {
|
||||
originTask, exist, err := model.GetByTaskId(info.UserId, info.OriginTaskID)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "get_origin_task_failed", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -88,7 +88,7 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
taskErr = service.TaskErrorWrapperLocal(errors.New("task_origin_not_exist"), "task_not_exist", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if originTask.ChannelId != relayInfo.ChannelId {
|
||||
if originTask.ChannelId != info.ChannelId {
|
||||
channel, err := model.GetChannelById(originTask.ChannelId, true)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapperLocal(err, "channel_not_found", http.StatusBadRequest)
|
||||
@@ -101,19 +101,19 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
c.Set("channel_id", originTask.ChannelId)
|
||||
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
|
||||
|
||||
relayInfo.ChannelBaseUrl = channel.GetBaseURL()
|
||||
relayInfo.ChannelId = originTask.ChannelId
|
||||
info.ChannelBaseUrl = channel.GetBaseURL()
|
||||
info.ChannelId = originTask.ChannelId
|
||||
}
|
||||
}
|
||||
|
||||
// build body
|
||||
requestBody, err := adaptor.BuildRequestBody(c, relayInfo)
|
||||
requestBody, err := adaptor.BuildRequestBody(c, info)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// do request
|
||||
resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -127,9 +127,9 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
|
||||
defer func() {
|
||||
// release quota
|
||||
if relayInfo.ConsumeQuota && taskErr == nil {
|
||||
if info.ConsumeQuota && taskErr == nil {
|
||||
|
||||
err := service.PostConsumeQuota(relayInfo.RelayInfo, quota, 0, true)
|
||||
err := service.PostConsumeQuota(info, quota, 0, true)
|
||||
if err != nil {
|
||||
common.SysLog("error consuming token remain quota: " + err.Error())
|
||||
}
|
||||
@@ -139,40 +139,40 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
|
||||
if hasUserGroupRatio {
|
||||
gRatio = userGroupRatio
|
||||
}
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, relayInfo.Action)
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, info.Action)
|
||||
other := make(map[string]interface{})
|
||||
other["model_price"] = modelPrice
|
||||
other["group_ratio"] = groupRatio
|
||||
if hasUserGroupRatio {
|
||||
other["user_group_ratio"] = userGroupRatio
|
||||
}
|
||||
model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||
ChannelId: relayInfo.ChannelId,
|
||||
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
|
||||
ChannelId: info.ChannelId,
|
||||
ModelName: modelName,
|
||||
TokenName: tokenName,
|
||||
Quota: quota,
|
||||
Content: logContent,
|
||||
TokenId: relayInfo.TokenId,
|
||||
Group: relayInfo.UsingGroup,
|
||||
TokenId: info.TokenId,
|
||||
Group: info.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
|
||||
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
|
||||
model.UpdateUserUsedQuotaAndRequestCount(info.UserId, quota)
|
||||
model.UpdateChannelUsedQuota(info.ChannelId, quota)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
taskID, taskData, taskErr := adaptor.DoResponse(c, resp, relayInfo)
|
||||
taskID, taskData, taskErr := adaptor.DoResponse(c, resp, info)
|
||||
if taskErr != nil {
|
||||
return
|
||||
}
|
||||
relayInfo.ConsumeQuota = true
|
||||
info.ConsumeQuota = true
|
||||
// insert task
|
||||
task := model.InitTask(platform, relayInfo)
|
||||
task := model.InitTask(platform, info)
|
||||
task.TaskID = taskID
|
||||
task.Quota = quota
|
||||
task.Data = taskData
|
||||
task.Action = relayInfo.Action
|
||||
task.Action = info.Action
|
||||
err = task.Insert()
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "insert_task_failed", http.StatusInternalServerError)
|
||||
|
||||
@@ -81,7 +81,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
if resp != nil {
|
||||
httpResp = resp.(*http.Response)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
|
||||
@@ -41,7 +41,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
adaptor.Init(info)
|
||||
var requestBody io.Reader
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled {
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
|
||||
body, err := common.GetRequestBody(c)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry())
|
||||
@@ -82,7 +82,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
httpResp = resp.(*http.Response)
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
|
||||
@@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
channelRoute.GET("/models", controller.ChannelListModels)
|
||||
channelRoute.GET("/models_enabled", controller.EnabledListModels)
|
||||
channelRoute.GET("/:id", controller.GetChannel)
|
||||
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey)
|
||||
channelRoute.GET("/test", controller.TestAllChannels)
|
||||
channelRoute.GET("/test/:id", controller.TestChannel)
|
||||
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
|
||||
@@ -223,6 +224,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
modelsRoute := apiRouter.Group("/models")
|
||||
modelsRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview)
|
||||
modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels)
|
||||
modelsRoute.GET("/missing", controller.GetMissingModels)
|
||||
modelsRoute.GET("/", controller.GetAllModelsMeta)
|
||||
modelsRoute.GET("/search", controller.SearchModelsMeta)
|
||||
|
||||
@@ -248,9 +248,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
},
|
||||
})
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "content_block_delta",
|
||||
Index: &info.ClaudeConvertInfo.Index,
|
||||
Type: "content_block_delta",
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Type: "text_delta",
|
||||
Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
"one-api/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -78,7 +80,7 @@ func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.Claude
|
||||
return claudeErr
|
||||
}
|
||||
|
||||
func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) {
|
||||
func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) {
|
||||
newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
@@ -94,7 +96,7 @@ func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *t
|
||||
newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
|
||||
} else {
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
|
||||
logger.LogInfo(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
|
||||
}
|
||||
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
|
||||
@@ -21,6 +21,10 @@ func DecodeBase64ImageData(base64String string) (image.Config, string, string, e
|
||||
base64String = base64String[idx+1:]
|
||||
}
|
||||
|
||||
if len(base64String) == 0 {
|
||||
return image.Config{}, "", "", errors.New("base64 string is empty")
|
||||
}
|
||||
|
||||
// 将base64字符串解码为字节切片
|
||||
decodedData, err := base64.StdEncoding.DecodeString(base64String)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -14,13 +13,13 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, preConsumedQuota int) {
|
||||
if preConsumedQuota != 0 {
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota)))
|
||||
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||
if relayInfo.FinalPreConsumedQuota != 0 {
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
|
||||
gopool.Go(func() {
|
||||
relayInfoCopy := *relayInfo
|
||||
|
||||
err := PostConsumeQuota(&relayInfoCopy, -preConsumedQuota, 0, false)
|
||||
err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false)
|
||||
if err != nil {
|
||||
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||
}
|
||||
@@ -30,16 +29,16 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, pr
|
||||
|
||||
// PreConsumeQuota checks if the user has enough quota to pre-consume.
|
||||
// It returns the pre-consumed quota if successful, or an error if not.
|
||||
func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) (int, *types.NewAPIError) {
|
||||
func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
|
||||
if err != nil {
|
||||
return 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||
return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
if userQuota <= 0 {
|
||||
return 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
if userQuota-preConsumedQuota < 0 {
|
||||
return 0, types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
|
||||
trustQuota := common.GetTrustQuota()
|
||||
@@ -66,14 +65,14 @@ func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
|
||||
if preConsumedQuota > 0 {
|
||||
err := PreConsumeTokenQuota(relayInfo, preConsumedQuota)
|
||||
if err != nil {
|
||||
return 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota)
|
||||
if err != nil {
|
||||
return 0, types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
|
||||
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota)))
|
||||
}
|
||||
relayInfo.FinalPreConsumedQuota = preConsumedQuota
|
||||
return preConsumedQuota, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -535,8 +535,27 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
|
||||
if quotaTooLow {
|
||||
prompt := "您的额度即将用尽"
|
||||
topUpLink := fmt.Sprintf("%s/topup", setting.ServerAddress)
|
||||
content := "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
|
||||
err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}))
|
||||
|
||||
// 根据通知方式生成不同的内容格式
|
||||
var content string
|
||||
var values []interface{}
|
||||
|
||||
notifyType := userSetting.NotifyType
|
||||
if notifyType == "" {
|
||||
notifyType = dto.NotifyTypeEmail
|
||||
}
|
||||
|
||||
if notifyType == dto.NotifyTypeBark {
|
||||
// Bark推送使用简短文本,不支持HTML
|
||||
content = "{{value}},剩余额度:{{value}},请及时充值"
|
||||
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
|
||||
} else {
|
||||
// 默认内容格式,适用于Email和Webhook
|
||||
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
|
||||
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
|
||||
}
|
||||
|
||||
err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values))
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error()))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"math"
|
||||
"one-api/common"
|
||||
@@ -250,13 +253,18 @@ func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, er
|
||||
}
|
||||
|
||||
func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
|
||||
if meta == nil {
|
||||
return 0, errors.New("token count meta is nil")
|
||||
if !constant.GetMediaToken {
|
||||
return 0, nil
|
||||
}
|
||||
if !constant.GetMediaTokenNotStream && !info.IsStream {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if info.RelayFormat == types.RelayFormatOpenAIRealtime {
|
||||
return 0, nil
|
||||
}
|
||||
if meta == nil {
|
||||
return 0, errors.New("token count meta is nil")
|
||||
}
|
||||
|
||||
model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
|
||||
tkm := 0
|
||||
@@ -276,7 +284,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
|
||||
|
||||
shouldFetchFiles := true
|
||||
|
||||
if info.RelayFormat == types.RelayFormatOpenAIRealtime || info.RelayFormat == types.RelayFormatGemini {
|
||||
if info.RelayFormat == types.RelayFormatGemini {
|
||||
shouldFetchFiles = false
|
||||
}
|
||||
|
||||
@@ -297,19 +305,43 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
|
||||
file.FileType = types.FileTypeFile
|
||||
}
|
||||
file.MimeType = mineType
|
||||
} else if strings.HasPrefix(file.OriginData, "data:") {
|
||||
// get mime type from base64 header
|
||||
parts := strings.SplitN(file.OriginData, ",", 2)
|
||||
if len(parts) >= 1 {
|
||||
header := parts[0]
|
||||
// Extract mime type from "data:mime/type;base64" format
|
||||
if strings.Contains(header, ":") && strings.Contains(header, ";") {
|
||||
mimeStart := strings.Index(header, ":") + 1
|
||||
mimeEnd := strings.Index(header, ";")
|
||||
if mimeStart < mimeEnd {
|
||||
mineType := header[mimeStart:mimeEnd]
|
||||
if strings.HasPrefix(mineType, "image/") {
|
||||
file.FileType = types.FileTypeImage
|
||||
} else if strings.HasPrefix(mineType, "video/") {
|
||||
file.FileType = types.FileTypeVideo
|
||||
} else if strings.HasPrefix(mineType, "audio/") {
|
||||
file.FileType = types.FileTypeAudio
|
||||
} else {
|
||||
file.FileType = types.FileTypeFile
|
||||
}
|
||||
file.MimeType = mineType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range meta.Files {
|
||||
for i, file := range meta.Files {
|
||||
switch file.FileType {
|
||||
case types.FileTypeImage:
|
||||
if info.RelayFormat == types.RelayFormatGemini {
|
||||
if info.RelayFormat == types.RelayFormatGemini && !strings.HasPrefix(model, "gemini-2.5-flash-image-preview") {
|
||||
tkm += 256
|
||||
} else {
|
||||
token, err := getImageToken(file, model, info.IsStream)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error counting image token: %v", err)
|
||||
return 0, fmt.Errorf("error counting image token, media index[%d], original data[%s], err: %v", i, file.OriginData, err)
|
||||
}
|
||||
tkm += token
|
||||
}
|
||||
@@ -328,33 +360,6 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
|
||||
return tkm, nil
|
||||
}
|
||||
|
||||
//func CountTokenChatRequest(info *relaycommon.RelayInfo, request dto.GeneralOpenAIRequest) (int, error) {
|
||||
// tkm := 0
|
||||
// msgTokens, err := CountTokenMessages(info, request.Messages, request.Model, request.Stream)
|
||||
// if err != nil {
|
||||
// return 0, err
|
||||
// }
|
||||
// tkm += msgTokens
|
||||
// if request.Tools != nil {
|
||||
// openaiTools := request.Tools
|
||||
// countStr := ""
|
||||
// for _, tool := range openaiTools {
|
||||
// countStr = tool.Function.Name
|
||||
// if tool.Function.Description != "" {
|
||||
// countStr += tool.Function.Description
|
||||
// }
|
||||
// if tool.Function.Parameters != nil {
|
||||
// countStr += fmt.Sprintf("%v", tool.Function.Parameters)
|
||||
// }
|
||||
// }
|
||||
// toolTokens := CountTokenInput(countStr, request.Model)
|
||||
// tkm += 8
|
||||
// tkm += toolTokens
|
||||
// }
|
||||
//
|
||||
// return tkm, nil
|
||||
//}
|
||||
|
||||
func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, error) {
|
||||
tkm := 0
|
||||
|
||||
@@ -514,56 +519,6 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent,
|
||||
return textToken, audioToken, nil
|
||||
}
|
||||
|
||||
//func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, model string, stream bool) (int, error) {
|
||||
// //recover when panic
|
||||
// tokenEncoder := getTokenEncoder(model)
|
||||
// // Reference:
|
||||
// // https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
|
||||
// // https://github.com/pkoukk/tiktoken-go/issues/6
|
||||
// //
|
||||
// // Every message follows <|start|>{role/name}\n{content}<|end|>\n
|
||||
// var tokensPerMessage int
|
||||
// var tokensPerName int
|
||||
//
|
||||
// tokensPerMessage = 3
|
||||
// tokensPerName = 1
|
||||
//
|
||||
// tokenNum := 0
|
||||
// for _, message := range messages {
|
||||
// tokenNum += tokensPerMessage
|
||||
// tokenNum += getTokenNum(tokenEncoder, message.Role)
|
||||
// if message.Content != nil {
|
||||
// if message.Name != nil {
|
||||
// tokenNum += tokensPerName
|
||||
// tokenNum += getTokenNum(tokenEncoder, *message.Name)
|
||||
// }
|
||||
// arrayContent := message.ParseContent()
|
||||
// for _, m := range arrayContent {
|
||||
// if m.Type == dto.ContentTypeImageURL {
|
||||
// imageUrl := m.GetImageMedia()
|
||||
// imageTokenNum, err := getImageToken(info, imageUrl, model, stream)
|
||||
// if err != nil {
|
||||
// return 0, err
|
||||
// }
|
||||
// tokenNum += imageTokenNum
|
||||
// log.Printf("image token num: %d", imageTokenNum)
|
||||
// } else if m.Type == dto.ContentTypeInputAudio {
|
||||
// // TODO: 音频token数量计算
|
||||
// tokenNum += 100
|
||||
// } else if m.Type == dto.ContentTypeFile {
|
||||
// tokenNum += 5000
|
||||
// } else if m.Type == dto.ContentTypeVideoUrl {
|
||||
// tokenNum += 5000
|
||||
// } else {
|
||||
// tokenNum += getTokenNum(tokenEncoder, m.Text)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|>
|
||||
// return tokenNum, nil
|
||||
//}
|
||||
|
||||
func CountTokenInput(input any, model string) int {
|
||||
switch v := input.(type) {
|
||||
case string:
|
||||
|
||||
@@ -2,9 +2,12 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/model"
|
||||
"one-api/setting"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +54,13 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
|
||||
// 获取 webhook secret
|
||||
webhookSecret := userSetting.WebhookSecret
|
||||
return SendWebhookNotify(webhookURLStr, webhookSecret, data)
|
||||
case dto.NotifyTypeBark:
|
||||
barkURL := userSetting.BarkUrl
|
||||
if barkURL == "" {
|
||||
common.SysLog(fmt.Sprintf("user %d has no bark url, skip sending bark", userId))
|
||||
return nil
|
||||
}
|
||||
return sendBarkNotify(barkURL, data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -64,3 +74,67 @@ func sendEmailNotify(userEmail string, data dto.Notify) error {
|
||||
}
|
||||
return common.SendEmail(data.Title, userEmail, content)
|
||||
}
|
||||
|
||||
func sendBarkNotify(barkURL string, data dto.Notify) error {
|
||||
// 处理占位符
|
||||
content := data.Content
|
||||
for _, value := range data.Values {
|
||||
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
|
||||
}
|
||||
|
||||
// 替换模板变量
|
||||
finalURL := strings.ReplaceAll(barkURL, "{{title}}", url.QueryEscape(data.Title))
|
||||
finalURL = strings.ReplaceAll(finalURL, "{{content}}", url.QueryEscape(content))
|
||||
|
||||
// 发送GET请求到Bark
|
||||
var req *http.Request
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
if setting.EnableWorker() {
|
||||
// 使用worker发送请求
|
||||
workerReq := &WorkerRequest{
|
||||
URL: finalURL,
|
||||
Key: setting.WorkerValidKey,
|
||||
Method: http.MethodGet,
|
||||
Headers: map[string]string{
|
||||
"User-Agent": "OneAPI-Bark-Notify/1.0",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err = DoWorkerRequest(workerReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send bark request through worker: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
} else {
|
||||
// 直接发送请求
|
||||
req, err = http.NewRequest(http.MethodGet, finalURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bark request: %v", err)
|
||||
}
|
||||
|
||||
// 设置User-Agent
|
||||
req.Header.Set("User-Agent", "OneAPI-Bark-Notify/1.0")
|
||||
|
||||
// 发送请求
|
||||
client := GetHttpClient()
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send bark request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,37 +3,37 @@ package console_setting
|
||||
import "one-api/setting/config"
|
||||
|
||||
type ConsoleSetting struct {
|
||||
ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
|
||||
UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
|
||||
Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
|
||||
FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
|
||||
ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
|
||||
UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
|
||||
AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
|
||||
FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板
|
||||
ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
|
||||
UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
|
||||
Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
|
||||
FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
|
||||
ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
|
||||
UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
|
||||
AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
|
||||
FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultConsoleSetting = ConsoleSetting{
|
||||
ApiInfo: "",
|
||||
UptimeKumaGroups: "",
|
||||
Announcements: "",
|
||||
FAQ: "",
|
||||
ApiInfoEnabled: true,
|
||||
UptimeKumaEnabled: true,
|
||||
AnnouncementsEnabled: true,
|
||||
FAQEnabled: true,
|
||||
ApiInfo: "",
|
||||
UptimeKumaGroups: "",
|
||||
Announcements: "",
|
||||
FAQ: "",
|
||||
ApiInfoEnabled: true,
|
||||
UptimeKumaEnabled: true,
|
||||
AnnouncementsEnabled: true,
|
||||
FAQEnabled: true,
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
var consoleSetting = defaultConsoleSetting
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器,键名为 console_setting
|
||||
config.GlobalConfig.Register("console_setting", &consoleSetting)
|
||||
// 注册到全局配置管理器,键名为 console_setting
|
||||
config.GlobalConfig.Register("console_setting", &consoleSetting)
|
||||
}
|
||||
|
||||
// GetConsoleSetting 获取 ConsoleSetting 配置实例
|
||||
func GetConsoleSetting() *ConsoleSetting {
|
||||
return &consoleSetting
|
||||
}
|
||||
return &consoleSetting
|
||||
}
|
||||
|
||||
@@ -1,304 +1,304 @@
|
||||
package console_setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"sort"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`)
|
||||
dangerousChars = []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
||||
validColors = map[string]bool{
|
||||
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
|
||||
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
|
||||
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
|
||||
"violet": true, "grey": true,
|
||||
}
|
||||
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`)
|
||||
dangerousChars = []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
||||
validColors = map[string]bool{
|
||||
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
|
||||
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
|
||||
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
|
||||
"violet": true, "grey": true,
|
||||
}
|
||||
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
)
|
||||
|
||||
func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) {
|
||||
var list []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
|
||||
return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error())
|
||||
}
|
||||
return list, nil
|
||||
var list []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
|
||||
return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error())
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func validateURL(urlStr string, index int, itemType string) error {
|
||||
if !urlRegex.MatchString(urlStr) {
|
||||
return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
|
||||
}
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return fmt.Errorf("第%d个%s的URL无法解析:%s", index, itemType, err.Error())
|
||||
}
|
||||
return nil
|
||||
if !urlRegex.MatchString(urlStr) {
|
||||
return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
|
||||
}
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return fmt.Errorf("第%d个%s的URL无法解析:%s", index, itemType, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkDangerousContent(content string, index int, itemType string) error {
|
||||
lower := strings.ToLower(content)
|
||||
for _, d := range dangerousChars {
|
||||
if strings.Contains(lower, d) {
|
||||
return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
lower := strings.ToLower(content)
|
||||
for _, d := range dangerousChars {
|
||||
if strings.Contains(lower, d) {
|
||||
return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getJSONList(jsonStr string) []map[string]interface{} {
|
||||
if jsonStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var list []map[string]interface{}
|
||||
json.Unmarshal([]byte(jsonStr), &list)
|
||||
return list
|
||||
if jsonStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var list []map[string]interface{}
|
||||
json.Unmarshal([]byte(jsonStr), &list)
|
||||
return list
|
||||
}
|
||||
|
||||
func ValidateConsoleSettings(settingsStr string, settingType string) error {
|
||||
if settingsStr == "" {
|
||||
return nil
|
||||
}
|
||||
if settingsStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch settingType {
|
||||
case "ApiInfo":
|
||||
return validateApiInfo(settingsStr)
|
||||
case "Announcements":
|
||||
return validateAnnouncements(settingsStr)
|
||||
case "FAQ":
|
||||
return validateFAQ(settingsStr)
|
||||
case "UptimeKumaGroups":
|
||||
return validateUptimeKumaGroups(settingsStr)
|
||||
default:
|
||||
return fmt.Errorf("未知的设置类型:%s", settingType)
|
||||
}
|
||||
switch settingType {
|
||||
case "ApiInfo":
|
||||
return validateApiInfo(settingsStr)
|
||||
case "Announcements":
|
||||
return validateAnnouncements(settingsStr)
|
||||
case "FAQ":
|
||||
return validateFAQ(settingsStr)
|
||||
case "UptimeKumaGroups":
|
||||
return validateUptimeKumaGroups(settingsStr)
|
||||
default:
|
||||
return fmt.Errorf("未知的设置类型:%s", settingType)
|
||||
}
|
||||
}
|
||||
|
||||
func validateApiInfo(apiInfoStr string) error {
|
||||
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(apiInfoList) > 50 {
|
||||
return fmt.Errorf("API信息数量不能超过50个")
|
||||
}
|
||||
if len(apiInfoList) > 50 {
|
||||
return fmt.Errorf("API信息数量不能超过50个")
|
||||
}
|
||||
|
||||
for i, apiInfo := range apiInfoList {
|
||||
urlStr, ok := apiInfo["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
|
||||
}
|
||||
route, ok := apiInfo["route"].(string)
|
||||
if !ok || route == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
|
||||
}
|
||||
description, ok := apiInfo["description"].(string)
|
||||
if !ok || description == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
|
||||
}
|
||||
color, ok := apiInfo["color"].(string)
|
||||
if !ok || color == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
|
||||
}
|
||||
for i, apiInfo := range apiInfoList {
|
||||
urlStr, ok := apiInfo["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
|
||||
}
|
||||
route, ok := apiInfo["route"].(string)
|
||||
if !ok || route == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
|
||||
}
|
||||
description, ok := apiInfo["description"].(string)
|
||||
if !ok || description == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
|
||||
}
|
||||
color, ok := apiInfo["color"].(string)
|
||||
if !ok || color == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
|
||||
}
|
||||
|
||||
if err := validateURL(urlStr, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateURL(urlStr, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(urlStr) > 500 {
|
||||
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
|
||||
}
|
||||
if len(route) > 100 {
|
||||
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
|
||||
}
|
||||
if len(description) > 200 {
|
||||
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
|
||||
}
|
||||
if len(urlStr) > 500 {
|
||||
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
|
||||
}
|
||||
if len(route) > 100 {
|
||||
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
|
||||
}
|
||||
if len(description) > 200 {
|
||||
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
|
||||
}
|
||||
|
||||
if !validColors[color] {
|
||||
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
|
||||
}
|
||||
if !validColors[color] {
|
||||
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
|
||||
}
|
||||
|
||||
if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetApiInfo() []map[string]interface{} {
|
||||
return getJSONList(GetConsoleSetting().ApiInfo)
|
||||
return getJSONList(GetConsoleSetting().ApiInfo)
|
||||
}
|
||||
|
||||
func validateAnnouncements(announcementsStr string) error {
|
||||
list, err := parseJSONArray(announcementsStr, "系统公告")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 100 {
|
||||
return fmt.Errorf("系统公告数量不能超过100个")
|
||||
}
|
||||
validTypes := map[string]bool{
|
||||
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
|
||||
}
|
||||
for i, ann := range list {
|
||||
content, ok := ann["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
|
||||
}
|
||||
publishDateAny, exists := ann["publishDate"]
|
||||
if !exists {
|
||||
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
|
||||
}
|
||||
publishDateStr, ok := publishDateAny.(string)
|
||||
if !ok || publishDateStr == "" {
|
||||
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
|
||||
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
|
||||
}
|
||||
if t, exists := ann["type"]; exists {
|
||||
if typeStr, ok := t.(string); ok {
|
||||
if !validTypes[typeStr] {
|
||||
return fmt.Errorf("第%d个公告的类型值不合法", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(content) > 500 {
|
||||
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
|
||||
}
|
||||
if extra, exists := ann["extra"]; exists {
|
||||
if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
|
||||
return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
list, err := parseJSONArray(announcementsStr, "系统公告")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 100 {
|
||||
return fmt.Errorf("系统公告数量不能超过100个")
|
||||
}
|
||||
validTypes := map[string]bool{
|
||||
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
|
||||
}
|
||||
for i, ann := range list {
|
||||
content, ok := ann["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
|
||||
}
|
||||
publishDateAny, exists := ann["publishDate"]
|
||||
if !exists {
|
||||
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
|
||||
}
|
||||
publishDateStr, ok := publishDateAny.(string)
|
||||
if !ok || publishDateStr == "" {
|
||||
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
|
||||
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
|
||||
}
|
||||
if t, exists := ann["type"]; exists {
|
||||
if typeStr, ok := t.(string); ok {
|
||||
if !validTypes[typeStr] {
|
||||
return fmt.Errorf("第%d个公告的类型值不合法", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(content) > 500 {
|
||||
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
|
||||
}
|
||||
if extra, exists := ann["extra"]; exists {
|
||||
if extraStr, ok := extra.(string); ok && len(extraStr) > 200 {
|
||||
return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFAQ(faqStr string) error {
|
||||
list, err := parseJSONArray(faqStr, "FAQ信息")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 100 {
|
||||
return fmt.Errorf("FAQ数量不能超过100个")
|
||||
}
|
||||
for i, faq := range list {
|
||||
question, ok := faq["question"].(string)
|
||||
if !ok || question == "" {
|
||||
return fmt.Errorf("第%d个FAQ缺少问题字段", i+1)
|
||||
}
|
||||
answer, ok := faq["answer"].(string)
|
||||
if !ok || answer == "" {
|
||||
return fmt.Errorf("第%d个FAQ缺少答案字段", i+1)
|
||||
}
|
||||
if len(question) > 200 {
|
||||
return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1)
|
||||
}
|
||||
if len(answer) > 1000 {
|
||||
return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
list, err := parseJSONArray(faqStr, "FAQ信息")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 100 {
|
||||
return fmt.Errorf("FAQ数量不能超过100个")
|
||||
}
|
||||
for i, faq := range list {
|
||||
question, ok := faq["question"].(string)
|
||||
if !ok || question == "" {
|
||||
return fmt.Errorf("第%d个FAQ缺少问题字段", i+1)
|
||||
}
|
||||
answer, ok := faq["answer"].(string)
|
||||
if !ok || answer == "" {
|
||||
return fmt.Errorf("第%d个FAQ缺少答案字段", i+1)
|
||||
}
|
||||
if len(question) > 200 {
|
||||
return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1)
|
||||
}
|
||||
if len(answer) > 1000 {
|
||||
return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPublishTime(item map[string]interface{}) time.Time {
|
||||
if v, ok := item["publishDate"]; ok {
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
if v, ok := item["publishDate"]; ok {
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func GetAnnouncements() []map[string]interface{} {
|
||||
list := getJSONList(GetConsoleSetting().Announcements)
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
return getPublishTime(list[i]).After(getPublishTime(list[j]))
|
||||
})
|
||||
return list
|
||||
list := getJSONList(GetConsoleSetting().Announcements)
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
return getPublishTime(list[i]).After(getPublishTime(list[j]))
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
func GetFAQ() []map[string]interface{} {
|
||||
return getJSONList(GetConsoleSetting().FAQ)
|
||||
return getJSONList(GetConsoleSetting().FAQ)
|
||||
}
|
||||
|
||||
func validateUptimeKumaGroups(groupsStr string) error {
|
||||
groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(groups) > 20 {
|
||||
return fmt.Errorf("Uptime Kuma分组数量不能超过20个")
|
||||
}
|
||||
if len(groups) > 20 {
|
||||
return fmt.Errorf("Uptime Kuma分组数量不能超过20个")
|
||||
}
|
||||
|
||||
nameSet := make(map[string]bool)
|
||||
nameSet := make(map[string]bool)
|
||||
|
||||
for i, group := range groups {
|
||||
categoryName, ok := group["categoryName"].(string)
|
||||
if !ok || categoryName == "" {
|
||||
return fmt.Errorf("第%d个分组缺少分类名称字段", i+1)
|
||||
}
|
||||
if nameSet[categoryName] {
|
||||
return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1)
|
||||
}
|
||||
nameSet[categoryName] = true
|
||||
urlStr, ok := group["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个分组缺少URL字段", i+1)
|
||||
}
|
||||
slug, ok := group["slug"].(string)
|
||||
if !ok || slug == "" {
|
||||
return fmt.Errorf("第%d个分组缺少Slug字段", i+1)
|
||||
}
|
||||
description, ok := group["description"].(string)
|
||||
if !ok {
|
||||
description = ""
|
||||
}
|
||||
for i, group := range groups {
|
||||
categoryName, ok := group["categoryName"].(string)
|
||||
if !ok || categoryName == "" {
|
||||
return fmt.Errorf("第%d个分组缺少分类名称字段", i+1)
|
||||
}
|
||||
if nameSet[categoryName] {
|
||||
return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1)
|
||||
}
|
||||
nameSet[categoryName] = true
|
||||
urlStr, ok := group["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个分组缺少URL字段", i+1)
|
||||
}
|
||||
slug, ok := group["slug"].(string)
|
||||
if !ok || slug == "" {
|
||||
return fmt.Errorf("第%d个分组缺少Slug字段", i+1)
|
||||
}
|
||||
description, ok := group["description"].(string)
|
||||
if !ok {
|
||||
description = ""
|
||||
}
|
||||
|
||||
if err := validateURL(urlStr, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateURL(urlStr, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(categoryName) > 50 {
|
||||
return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1)
|
||||
}
|
||||
if len(urlStr) > 500 {
|
||||
return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1)
|
||||
}
|
||||
if len(slug) > 100 {
|
||||
return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1)
|
||||
}
|
||||
if len(description) > 200 {
|
||||
return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1)
|
||||
}
|
||||
if len(categoryName) > 50 {
|
||||
return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1)
|
||||
}
|
||||
if len(urlStr) > 500 {
|
||||
return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1)
|
||||
}
|
||||
if len(slug) > 100 {
|
||||
return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1)
|
||||
}
|
||||
if len(description) > 200 {
|
||||
return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1)
|
||||
}
|
||||
|
||||
if !slugRegex.MatchString(slug) {
|
||||
return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1)
|
||||
}
|
||||
if !slugRegex.MatchString(slug) {
|
||||
return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1)
|
||||
}
|
||||
|
||||
if err := checkDangerousContent(description, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if err := checkDangerousContent(description, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetUptimeKumaGroups() []map[string]interface{} {
|
||||
return getJSONList(GetConsoleSetting().UptimeKumaGroups)
|
||||
}
|
||||
return getJSONList(GetConsoleSetting().UptimeKumaGroups)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var defaultGeminiSettings = GeminiSettings{
|
||||
SupportedImagineModels: []string{
|
||||
"gemini-2.0-flash-exp-image-generation",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.5-flash-image-preview",
|
||||
},
|
||||
ThinkingAdapterEnabled: false,
|
||||
ThinkingAdapterBudgetTokensPercentage: 0.6,
|
||||
|
||||
34
setting/operation_setting/monitor_setting.go
Normal file
34
setting/operation_setting/monitor_setting.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package operation_setting
|
||||
|
||||
import (
|
||||
"one-api/setting/config"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type MonitorSetting struct {
|
||||
AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"`
|
||||
AutoTestChannelMinutes int `json:"auto_test_channel_minutes"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var monitorSetting = MonitorSetting{
|
||||
AutoTestChannelEnabled: false,
|
||||
AutoTestChannelMinutes: 10,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("monitor_setting", &monitorSetting)
|
||||
}
|
||||
|
||||
func GetMonitorSetting() *MonitorSetting {
|
||||
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
|
||||
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
|
||||
if err == nil && frequency > 0 {
|
||||
monitorSetting.AutoTestChannelEnabled = true
|
||||
monitorSetting.AutoTestChannelMinutes = frequency
|
||||
}
|
||||
}
|
||||
return &monitorSetting
|
||||
}
|
||||
@@ -24,6 +24,10 @@ const (
|
||||
ClaudeWebSearchPrice = 10.00
|
||||
)
|
||||
|
||||
const (
|
||||
Gemini25FlashImagePreviewImageOutputPrice = 30.00
|
||||
)
|
||||
|
||||
func GetClaudeWebSearchPricePerThousand() float64 {
|
||||
return ClaudeWebSearchPrice
|
||||
}
|
||||
@@ -32,11 +36,12 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
|
||||
// 确定模型类型
|
||||
// https://platform.openai.com/docs/pricing Web search 价格按模型类型收费
|
||||
// 新版计费规则不再关联 search context size,故在const区域将各size的价格设为一致。
|
||||
// gpt-4o and gpt-4.1 models (including mini models) 等模型更贵,o3, o4-mini, o3-pro, and deep research models 等模型更便宜
|
||||
// gpt-5, gpt-5-mini, gpt-5-nano 和 o 系列模型价格为 10.00 美元/千次调用,产生额外 token 计入 input_tokens
|
||||
// gpt-4o, gpt-4.1, gpt-4o-mini 和 gpt-4.1-mini 价格为 25.00 美元/千次调用,不产生额外 token
|
||||
isNormalPriceModel :=
|
||||
strings.HasPrefix(modelName, "o3") ||
|
||||
strings.HasPrefix(modelName, "o4") ||
|
||||
strings.Contains(modelName, "deep-research")
|
||||
strings.HasPrefix(modelName, "gpt-5")
|
||||
var priceWebSearchPerThousandCalls float64
|
||||
if isNormalPriceModel {
|
||||
priceWebSearchPerThousandCalls = WebSearchPrice
|
||||
@@ -64,3 +69,10 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func GetGeminiImageOutputPricePerMillionTokens(modelName string) float64 {
|
||||
if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
|
||||
return Gemini25FlashImagePreviewImageOutputPrice
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import "sync/atomic"
|
||||
var exposeRatioEnabled atomic.Bool
|
||||
|
||||
func init() {
|
||||
exposeRatioEnabled.Store(false)
|
||||
exposeRatioEnabled.Store(false)
|
||||
}
|
||||
|
||||
func SetExposeRatioEnabled(enabled bool) {
|
||||
exposeRatioEnabled.Store(enabled)
|
||||
exposeRatioEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
func IsExposeRatioEnabled() bool {
|
||||
return exposeRatioEnabled.Load()
|
||||
}
|
||||
return exposeRatioEnabled.Load()
|
||||
}
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
package ratio_setting
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const exposedDataTTL = 30 * time.Second
|
||||
|
||||
type exposedCache struct {
|
||||
data gin.H
|
||||
expiresAt time.Time
|
||||
data gin.H
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
exposedData atomic.Value
|
||||
rebuildMu sync.Mutex
|
||||
exposedData atomic.Value
|
||||
rebuildMu sync.Mutex
|
||||
)
|
||||
|
||||
func InvalidateExposedDataCache() {
|
||||
exposedData.Store((*exposedCache)(nil))
|
||||
exposedData.Store((*exposedCache)(nil))
|
||||
}
|
||||
|
||||
func cloneGinH(src gin.H) gin.H {
|
||||
dst := make(gin.H, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
dst := make(gin.H, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func GetExposedData() gin.H {
|
||||
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
|
||||
return cloneGinH(c.data)
|
||||
}
|
||||
rebuildMu.Lock()
|
||||
defer rebuildMu.Unlock()
|
||||
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
|
||||
return cloneGinH(c.data)
|
||||
}
|
||||
newData := gin.H{
|
||||
"model_ratio": GetModelRatioCopy(),
|
||||
"completion_ratio": GetCompletionRatioCopy(),
|
||||
"cache_ratio": GetCacheRatioCopy(),
|
||||
"model_price": GetModelPriceCopy(),
|
||||
}
|
||||
exposedData.Store(&exposedCache{
|
||||
data: newData,
|
||||
expiresAt: time.Now().Add(exposedDataTTL),
|
||||
})
|
||||
return cloneGinH(newData)
|
||||
}
|
||||
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
|
||||
return cloneGinH(c.data)
|
||||
}
|
||||
rebuildMu.Lock()
|
||||
defer rebuildMu.Unlock()
|
||||
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
|
||||
return cloneGinH(c.data)
|
||||
}
|
||||
newData := gin.H{
|
||||
"model_ratio": GetModelRatioCopy(),
|
||||
"completion_ratio": GetCompletionRatioCopy(),
|
||||
"cache_ratio": GetCacheRatioCopy(),
|
||||
"model_price": GetModelPriceCopy(),
|
||||
}
|
||||
exposedData.Store(&exposedCache{
|
||||
data: newData,
|
||||
expiresAt: time.Now().Add(exposedDataTTL),
|
||||
})
|
||||
return cloneGinH(newData)
|
||||
}
|
||||
|
||||
@@ -52,52 +52,52 @@ var defaultModelRatio = map[string]float64{
|
||||
"gpt-4o-realtime-preview-2024-12-17": 2.5,
|
||||
"gpt-4o-mini-realtime-preview": 0.3,
|
||||
"gpt-4o-mini-realtime-preview-2024-12-17": 0.3,
|
||||
"gpt-4.1": 1.0, // $2 / 1M tokens
|
||||
"gpt-4.1-2025-04-14": 1.0, // $2 / 1M tokens
|
||||
"gpt-4.1-mini": 0.2, // $0.4 / 1M tokens
|
||||
"gpt-4.1-mini-2025-04-14": 0.2, // $0.4 / 1M tokens
|
||||
"gpt-4.1-nano": 0.05, // $0.1 / 1M tokens
|
||||
"gpt-4.1-nano-2025-04-14": 0.05, // $0.1 / 1M tokens
|
||||
"gpt-image-1": 2.5, // $5 / 1M tokens
|
||||
"o1": 7.5, // $15 / 1M tokens
|
||||
"o1-2024-12-17": 7.5, // $15 / 1M tokens
|
||||
"o1-preview": 7.5, // $15 / 1M tokens
|
||||
"o1-preview-2024-09-12": 7.5, // $15 / 1M tokens
|
||||
"o1-mini": 0.55, // $1.1 / 1M tokens
|
||||
"o1-mini-2024-09-12": 0.55, // $1.1 / 1M tokens
|
||||
"o1-pro": 75.0, // $150 / 1M tokens
|
||||
"o1-pro-2025-03-19": 75.0, // $150 / 1M tokens
|
||||
"o3-mini": 0.55,
|
||||
"o3-mini-2025-01-31": 0.55,
|
||||
"o3-mini-high": 0.55,
|
||||
"o3-mini-2025-01-31-high": 0.55,
|
||||
"o3-mini-low": 0.55,
|
||||
"o3-mini-2025-01-31-low": 0.55,
|
||||
"o3-mini-medium": 0.55,
|
||||
"o3-mini-2025-01-31-medium": 0.55,
|
||||
"o3": 1.0, // $2 / 1M tokens
|
||||
"o3-2025-04-16": 1.0, // $2 / 1M tokens
|
||||
"o3-pro": 10.0, // $20 / 1M tokens
|
||||
"o3-pro-2025-06-10": 10.0, // $20 / 1M tokens
|
||||
"o3-deep-research": 5.0, // $10 / 1M tokens
|
||||
"o3-deep-research-2025-06-26": 5.0, // $10 / 1M tokens
|
||||
"o4-mini": 0.55, // $1.1 / 1M tokens
|
||||
"o4-mini-2025-04-16": 0.55, // $1.1 / 1M tokens
|
||||
"o4-mini-deep-research": 1.0, // $2 / 1M tokens
|
||||
"o4-mini-deep-research-2025-06-26": 1.0, // $2 / 1M tokens
|
||||
"gpt-4o-mini": 0.075,
|
||||
"gpt-4o-mini-2024-07-18": 0.075,
|
||||
"gpt-4-turbo": 5, // $0.01 / 1K tokens
|
||||
"gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens
|
||||
"gpt-4.5-preview": 37.5,
|
||||
"gpt-4.5-preview-2025-02-27": 37.5,
|
||||
"gpt-5": 0.625,
|
||||
"gpt-5-2025-08-07": 0.625,
|
||||
"gpt-5-chat-latest": 0.625,
|
||||
"gpt-5-mini": 0.125,
|
||||
"gpt-5-mini-2025-08-07": 0.125,
|
||||
"gpt-5-nano": 0.025,
|
||||
"gpt-5-nano-2025-08-07": 0.025,
|
||||
"gpt-4.1": 1.0, // $2 / 1M tokens
|
||||
"gpt-4.1-2025-04-14": 1.0, // $2 / 1M tokens
|
||||
"gpt-4.1-mini": 0.2, // $0.4 / 1M tokens
|
||||
"gpt-4.1-mini-2025-04-14": 0.2, // $0.4 / 1M tokens
|
||||
"gpt-4.1-nano": 0.05, // $0.1 / 1M tokens
|
||||
"gpt-4.1-nano-2025-04-14": 0.05, // $0.1 / 1M tokens
|
||||
"gpt-image-1": 2.5, // $5 / 1M tokens
|
||||
"o1": 7.5, // $15 / 1M tokens
|
||||
"o1-2024-12-17": 7.5, // $15 / 1M tokens
|
||||
"o1-preview": 7.5, // $15 / 1M tokens
|
||||
"o1-preview-2024-09-12": 7.5, // $15 / 1M tokens
|
||||
"o1-mini": 0.55, // $1.1 / 1M tokens
|
||||
"o1-mini-2024-09-12": 0.55, // $1.1 / 1M tokens
|
||||
"o1-pro": 75.0, // $150 / 1M tokens
|
||||
"o1-pro-2025-03-19": 75.0, // $150 / 1M tokens
|
||||
"o3-mini": 0.55,
|
||||
"o3-mini-2025-01-31": 0.55,
|
||||
"o3-mini-high": 0.55,
|
||||
"o3-mini-2025-01-31-high": 0.55,
|
||||
"o3-mini-low": 0.55,
|
||||
"o3-mini-2025-01-31-low": 0.55,
|
||||
"o3-mini-medium": 0.55,
|
||||
"o3-mini-2025-01-31-medium": 0.55,
|
||||
"o3": 1.0, // $2 / 1M tokens
|
||||
"o3-2025-04-16": 1.0, // $2 / 1M tokens
|
||||
"o3-pro": 10.0, // $20 / 1M tokens
|
||||
"o3-pro-2025-06-10": 10.0, // $20 / 1M tokens
|
||||
"o3-deep-research": 5.0, // $10 / 1M tokens
|
||||
"o3-deep-research-2025-06-26": 5.0, // $10 / 1M tokens
|
||||
"o4-mini": 0.55, // $1.1 / 1M tokens
|
||||
"o4-mini-2025-04-16": 0.55, // $1.1 / 1M tokens
|
||||
"o4-mini-deep-research": 1.0, // $2 / 1M tokens
|
||||
"o4-mini-deep-research-2025-06-26": 1.0, // $2 / 1M tokens
|
||||
"gpt-4o-mini": 0.075,
|
||||
"gpt-4o-mini-2024-07-18": 0.075,
|
||||
"gpt-4-turbo": 5, // $0.01 / 1K tokens
|
||||
"gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens
|
||||
"gpt-4.5-preview": 37.5,
|
||||
"gpt-4.5-preview-2025-02-27": 37.5,
|
||||
"gpt-5": 0.625,
|
||||
"gpt-5-2025-08-07": 0.625,
|
||||
"gpt-5-chat-latest": 0.625,
|
||||
"gpt-5-mini": 0.125,
|
||||
"gpt-5-mini-2025-08-07": 0.125,
|
||||
"gpt-5-nano": 0.025,
|
||||
"gpt-5-nano-2025-08-07": 0.025,
|
||||
//"gpt-3.5-turbo-0301": 0.75, //deprecated
|
||||
"gpt-3.5-turbo": 0.25,
|
||||
"gpt-3.5-turbo-0613": 0.75,
|
||||
@@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{
|
||||
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
|
||||
"gemini-2.5-flash-lite-preview-06-17": 0.05,
|
||||
"gemini-2.5-flash": 0.15,
|
||||
"gemini-2.5-flash-image-preview": 0.15, // $0.30(text/image) / 1M tokens
|
||||
"text-embedding-004": 0.001,
|
||||
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
|
||||
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
|
||||
@@ -293,10 +294,11 @@ var (
|
||||
)
|
||||
|
||||
var defaultCompletionRatio = map[string]float64{
|
||||
"gpt-4-gizmo-*": 2,
|
||||
"gpt-4o-gizmo-*": 3,
|
||||
"gpt-4-all": 2,
|
||||
"gpt-image-1": 8,
|
||||
"gpt-4-gizmo-*": 2,
|
||||
"gpt-4o-gizmo-*": 3,
|
||||
"gpt-4-all": 2,
|
||||
"gpt-image-1": 8,
|
||||
"gemini-2.5-flash-image-preview": 8.3333333333,
|
||||
}
|
||||
|
||||
// InitRatioSettings initializes all model related settings maps
|
||||
@@ -468,7 +470,13 @@ func GetCompletionRatio(name string) float64 {
|
||||
|
||||
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
lowercaseName := strings.ToLower(name)
|
||||
if strings.HasPrefix(name, "gpt-4") && !strings.HasSuffix(name, "-all") && !strings.HasSuffix(name, "-gizmo-*") {
|
||||
|
||||
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
|
||||
if isReservedModel {
|
||||
return 2, false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "gpt-") {
|
||||
if strings.HasPrefix(name, "gpt-4o") {
|
||||
if name == "gpt-4o-2024-05-13" {
|
||||
return 3, true
|
||||
@@ -535,7 +543,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
|
||||
return 4, false
|
||||
}
|
||||
return 2.5 / 0.3, true
|
||||
return 2.5 / 0.3, false
|
||||
}
|
||||
return 4, false
|
||||
}
|
||||
|
||||
@@ -145,13 +145,15 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
|
||||
Code: e.errorCode,
|
||||
}
|
||||
}
|
||||
default:
|
||||
result = OpenAIError{
|
||||
Message: e.Error(),
|
||||
Type: string(e.errorType),
|
||||
Param: "",
|
||||
Code: e.errorCode,
|
||||
}
|
||||
}
|
||||
result = OpenAIError{
|
||||
Message: e.Error(),
|
||||
Type: string(e.errorType),
|
||||
Param: "",
|
||||
Code: e.errorCode,
|
||||
}
|
||||
|
||||
result.Message = common.MaskSensitiveInfo(result.Message)
|
||||
return result
|
||||
}
|
||||
@@ -160,13 +162,16 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
|
||||
var result ClaudeError
|
||||
switch e.errorType {
|
||||
case ErrorTypeOpenAIError:
|
||||
openAIError := e.RelayError.(OpenAIError)
|
||||
result = ClaudeError{
|
||||
Message: e.Error(),
|
||||
Type: fmt.Sprintf("%v", openAIError.Code),
|
||||
if openAIError, ok := e.RelayError.(OpenAIError); ok {
|
||||
result = ClaudeError{
|
||||
Message: e.Error(),
|
||||
Type: fmt.Sprintf("%v", openAIError.Code),
|
||||
}
|
||||
}
|
||||
case ErrorTypeClaudeError:
|
||||
result = e.RelayError.(ClaudeError)
|
||||
if claudeError, ok := e.RelayError.(ClaudeError); ok {
|
||||
result = claudeError
|
||||
}
|
||||
default:
|
||||
result = ClaudeError{
|
||||
Message: e.Error(),
|
||||
@@ -180,6 +185,14 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
|
||||
type NewAPIErrorOptions func(*NewAPIError)
|
||||
|
||||
func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError {
|
||||
var newErr *NewAPIError
|
||||
// 保留深层传递的 new err
|
||||
if errors.As(err, &newErr) {
|
||||
for _, op := range ops {
|
||||
op(newErr)
|
||||
}
|
||||
return newErr
|
||||
}
|
||||
e := &NewAPIError{
|
||||
Err: err,
|
||||
RelayError: nil,
|
||||
@@ -194,8 +207,21 @@ func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPI
|
||||
}
|
||||
|
||||
func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
|
||||
if errorCode == ErrorCodeDoRequestFailed {
|
||||
err = errors.New("upstream error: do request failed")
|
||||
var newErr *NewAPIError
|
||||
// 保留深层传递的 new err
|
||||
if errors.As(err, &newErr) {
|
||||
if newErr.RelayError == nil {
|
||||
openaiError := OpenAIError{
|
||||
Message: newErr.Error(),
|
||||
Type: string(errorCode),
|
||||
Code: errorCode,
|
||||
}
|
||||
newErr.RelayError = openaiError
|
||||
}
|
||||
for _, op := range ops {
|
||||
op(newErr)
|
||||
}
|
||||
return newErr
|
||||
}
|
||||
openaiError := OpenAIError{
|
||||
Message: err.Error(),
|
||||
@@ -300,6 +326,15 @@ func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions {
|
||||
}
|
||||
}
|
||||
|
||||
func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions {
|
||||
return func(e *NewAPIError) {
|
||||
if common.DebugEnabled {
|
||||
fmt.Printf("ErrOptionWithHideErrMsg: %s, origin error: %s", replaceStr, e.Err)
|
||||
}
|
||||
e.Err = errors.New(replaceStr)
|
||||
}
|
||||
}
|
||||
|
||||
func IsRecordErrorLog(e *NewAPIError) bool {
|
||||
if e == nil {
|
||||
return false
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2021: true, node: true },
|
||||
parserOptions: { ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true } },
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ['header', 'react-hooks'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
rules: {
|
||||
'header/header': [2, 'block', [
|
||||
'',
|
||||
'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',
|
||||
''
|
||||
]],
|
||||
'no-multiple-empty-lines': ['error', { max: 1 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
'header/header': [
|
||||
2,
|
||||
'block',
|
||||
[
|
||||
'',
|
||||
'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',
|
||||
'',
|
||||
],
|
||||
],
|
||||
'no-multiple-empty-lines': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>New API</title>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="description" content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用" />
|
||||
<title>New API</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,4 +22,4 @@ export default {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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, { lazy, Suspense } from 'react';
|
||||
import React, { lazy, Suspense, useContext, useMemo } from 'react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import Loading from './components/common/ui/Loading';
|
||||
import User from './pages/User';
|
||||
@@ -27,6 +27,7 @@ import LoginForm from './components/auth/LoginForm';
|
||||
import NotFound from './pages/NotFound';
|
||||
import Forbidden from './pages/Forbidden';
|
||||
import Setting from './pages/Setting';
|
||||
import { StatusContext } from './context/Status';
|
||||
|
||||
import PasswordResetForm from './components/auth/PasswordResetForm';
|
||||
import PasswordResetConfirm from './components/auth/PasswordResetConfirm';
|
||||
@@ -53,6 +54,29 @@ const About = lazy(() => import('./pages/About'));
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
// 获取模型广场权限配置
|
||||
const pricingRequireAuth = useMemo(() => {
|
||||
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
|
||||
if (headerNavModulesConfig) {
|
||||
try {
|
||||
const modules = JSON.parse(headerNavModulesConfig);
|
||||
|
||||
// 处理向后兼容性:如果pricing是boolean,默认不需要登录
|
||||
if (typeof modules.pricing === 'boolean') {
|
||||
return false; // 默认不需要登录鉴权
|
||||
}
|
||||
|
||||
// 如果是对象格式,使用requireAuth配置
|
||||
return modules.pricing?.requireAuth === true;
|
||||
} catch (error) {
|
||||
console.error('解析顶栏模块配置失败:', error);
|
||||
return false; // 默认不需要登录
|
||||
}
|
||||
}
|
||||
return false; // 默认不需要登录
|
||||
}, [statusState?.status?.HeaderNavModules]);
|
||||
|
||||
return (
|
||||
<SetupCheck>
|
||||
@@ -73,10 +97,7 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/forbidden'
|
||||
element={<Forbidden />}
|
||||
/>
|
||||
<Route path='/forbidden' element={<Forbidden />} />
|
||||
<Route
|
||||
path='/console/models'
|
||||
element={
|
||||
@@ -256,9 +277,20 @@ function App() {
|
||||
<Route
|
||||
path='/pricing'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
pricingRequireAuth ? (
|
||||
<PrivateRoute>
|
||||
<Suspense
|
||||
fallback={<Loading></Loading>}
|
||||
key={location.pathname}
|
||||
>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
</PrivateRoute>
|
||||
) : (
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user