Compare commits

..

19 Commits

Author SHA1 Message Date
CaIon
13ff448049 refactor: replace log package with log/slog for structured logging and improve error handling 2025-11-06 22:25:00 +08:00
CaIon
3dc4d6c39e feat: restrict automatic channel testing to master node only 2025-11-06 21:12:59 +08:00
Seefs
019412c27a feat: EditTagModal header && param (#2159) 2025-11-06 20:18:45 +08:00
Seefs
96a2b81aaa add custom tool (#2157) 2025-11-06 20:18:25 +08:00
Seefs
fb610e62a0 fix playground (#2153) 2025-11-06 20:18:00 +08:00
CaIon
736f7b55b7 feat: add TASK_PRICE_PATCH environment variable for per-task billing configuration 2025-11-06 20:06:02 +08:00
Seefs
2fd33ea294 Merge pull request #2168 from feitianbubu/pr/fix-jimeng-1080p-image
fix: trim suffix p for jimeng image model
2025-11-06 19:54:02 +08:00
Seefs
53123aaf94 Merge pull request #2178 from LeonDevLifeLog/main
feat: add environment variable switch for critical rate limit
2025-11-06 19:48:28 +08:00
Seefs
f8f5d26600 Merge pull request #2182 from zhaolion/main
feat:  EditTokenModal 中针对用户创建的 token 默认无限额度
2025-11-06 19:41:27 +08:00
zhaolion
c86bc94d9d feat: EditTokenModal 中针对用户创建的 token 默认无限额度 2025-11-06 19:36:23 +08:00
Leon
50e8639a40 feat: add environment variable switch for critical rate limit 2025-11-06 15:23:34 +08:00
CaIon
424325162e feat: enhance Ali video request processing with resolution mapping and size validation 2025-11-05 16:02:39 +08:00
CaIon
a9a8676f7c fix: logger 2025-11-05 14:49:55 +08:00
feitianbubu
14295f0035 fix: trim suffix p for jimeng image model 2025-11-04 20:21:33 +08:00
IcedTangerine
29e70acc55 Merge pull request #2167 from feitianbubu/pr/fix-jimeng-v30-pro
修复即梦v30-pro视频生成失败问题
2025-11-04 18:37:44 +08:00
feitianbubu
8599b348c0 feat: jimeng_v30_pro only jimeng_ti2v_v30_pro model 2025-11-04 18:29:53 +08:00
IcedTangerine
6a761c2dba fix: openai 音频模型流模式未正确计费 (#2160) 2025-11-04 01:43:04 +08:00
Seefs
df2ee649ab feat: claude 1h cache (#2155)
* feat: claude 1h cache

* feat: claude 1h cache

* fix price
2025-11-04 00:20:50 +08:00
CaIon
00782aae88 refactor: comment out image file validation for qwen edit in Ali image processing 2025-11-01 14:31:32 +08:00
37 changed files with 1657 additions and 176 deletions

260
LOGGING.md Normal file
View File

@@ -0,0 +1,260 @@
# 日志系统说明
本项目使用 Go 标准库的 `log/slog` 实现结构化日志记录。
## 📋 功能特性
### 1. 标准的文件存储结构
- **当前日志文件**: `oneapi.log` - 实时写入的日志文件
- **归档日志文件**: `oneapi.2024-01-02-153045.log` - 自动轮转后的历史日志
### 2. 自动日志轮转
日志文件会在以下情况自动轮转:
- **按大小轮转**: 当日志文件超过指定大小时(默认 100MB
- **启动时日期检查**: 程序启动时如果检测到日志文件是旧日期创建的,会自动轮转
- **自动清理**: 只保留最近 N 个日志文件(默认 7 个)
### 3. 结构化日志
所有日志都包含以下结构化字段:
```
time=2024-01-02T15:30:45 level=INFO msg="user logged in" request_id=abc123 user_id=1001
```
### 4. 多种输出格式
- **Text 格式** (默认): 人类可读的文本格式
- **JSON 格式**: 便于日志分析工具解析
### 5. 灵活的日志级别
支持四个日志级别:
- `DEBUG`: 调试信息
- `INFO`: 一般信息
- `WARN`: 警告信息
- `ERROR`: 错误信息
## ⚙️ 配置方式
### 环境变量配置
```bash
# 日志目录(必需,否则只输出到控制台)
--log-dir=./logs
# 日志级别(可选,默认: INFODEBUG 模式除外)
export LOG_LEVEL=DEBUG # 可选值: DEBUG, INFO, WARN, ERROR
# 日志格式(可选,默认: text
export LOG_FORMAT=json # 可选值: text, json
# 单个日志文件最大大小(可选,默认: 100单位: MB
export LOG_MAX_SIZE_MB=200
# 保留的日志文件数量(可选,默认: 7
export LOG_MAX_FILES=14
# 启用调试模式(会自动将日志级别设为 DEBUG
export DEBUG=true
```
### 命令行参数
```bash
# 启动时指定日志目录
./new-api --log-dir=./logs
# 如果不指定日志目录,日志只输出到控制台
./new-api
```
## 📝 使用示例
### 基础使用
```go
import (
"context"
"github.com/QuantumNous/new-api/logger"
)
// 记录信息日志
logger.LogInfo(ctx, "user registered successfully")
// 记录警告日志
logger.LogWarn(ctx, "API rate limit approaching")
// 记录错误日志
logger.LogError(ctx, "failed to connect to database")
// 记录调试日志(只在 DEBUG 模式下输出)
logger.LogDebug(ctx, "processing request with params: %v", params)
// 记录系统日志(无 context
logger.LogSystemInfo("application started")
logger.LogSystemError("critical system error")
```
### 日志输出示例
**Text 格式** (易读格式):
```
[INFO] 2024/01/02 - 15:30:45 | SYSTEM | application started
[INFO] 2024/01/02 - 15:30:46 | abc123 | user registered successfully
[WARN] 2024/01/02 - 15:30:47 | def456 | API rate limit approaching | remaining=10, limit=100
[ERROR] 2024/01/02 - 15:30:48 | ghi789 | failed to connect to database | error="connection timeout"
```
格式说明:`[级别] 时间 | 请求ID/组件 | 消息 | 额外属性(如有)`
**JSON 格式**:
```json
{"time":"2024-01-02 15:30:45","level":"INFO","msg":"application started","request_id":"SYSTEM"}
{"time":"2024-01-02 15:30:46","level":"INFO","msg":"user registered successfully","request_id":"abc123"}
{"time":"2024-01-02 15:30:47","level":"WARN","msg":"API rate limit approaching","request_id":"def456"}
```
## 📂 日志文件结构
```
logs/
├── oneapi.log # 当前活动日志文件
├── oneapi.2024-01-01-090000.log # 昨天的日志
├── oneapi.2024-01-01-150000.log # 昨天下午的日志(如果超过大小限制)
├── oneapi.2023-12-31-090000.log # 更早的日志
└── ... # 最多保留配置数量的历史文件
```
## 🔄 日志轮转机制
### 轮转触发条件
1. **文件大小检查**: 每写入 1000 条日志后检查一次文件大小
2. **启动时日期检查**: 程序启动时检查日志文件的修改日期,如果不是今天则轮转
3. **自动清理**: 轮转时自动删除超过保留数量的旧日志文件
> **注意**: 日志不会在运行时动态检查日期变化。如果需要每天自动轮转日志,建议:
> - 使用定时任务(如 cron每天重启服务
> - 或者配置较小的日志文件大小,让它自动按大小轮转
### 轮转流程
1. 检测到需要轮转时,关闭当前日志文件
2.`oneapi.log` 重命名为 `oneapi.YYYY-MM-DD-HHmmss.log`
3. 创建新的 `oneapi.log` 文件
4. 异步清理超过数量限制的旧日志文件
5. 记录轮转事件到新日志文件
## 🎯 最佳实践
### 1. 生产环境配置
```bash
# 使用 INFO 级别,避免过多调试信息
export LOG_LEVEL=INFO
# 使用 JSON 格式,便于日志分析工具处理
export LOG_FORMAT=json
# 设置合适的文件大小和保留数量
export LOG_MAX_SIZE_MB=500
export LOG_MAX_FILES=30
# 指定日志目录
./new-api --log-dir=/var/log/oneapi
```
### 2. 开发环境配置
```bash
# 使用 DEBUG 级别查看详细信息
export DEBUG=true
# 使用 Text 格式,便于阅读
export LOG_FORMAT=text
# 较小的文件大小和保留数量
export LOG_MAX_SIZE_MB=50
export LOG_MAX_FILES=7
./new-api --log-dir=./logs
```
### 3. 容器环境配置
```bash
# 只输出到标准输出,由容器运行时管理日志
./new-api
# 或者使用 JSON 格式便于日志收集系统处理
export LOG_FORMAT=json
./new-api
```
## 🔍 日志分析
### 使用 grep 分析文本日志
```bash
# 查找错误日志
grep '\[ERROR\]' logs/oneapi.log
# 查找特定请求的所有日志
grep 'abc123' logs/*.log
# 查看最近的警告和错误
tail -f logs/oneapi.log | grep -E '\[(WARN|ERROR)\]'
# 查找包含特定关键词的日志
grep 'database' logs/oneapi.log
# 查看今天的所有错误
grep "\[ERROR\] $(date +%Y/%m/%d)" logs/oneapi.log
```
### 使用 jq 分析 JSON 日志
```bash
# 提取所有错误日志
cat logs/oneapi.log | jq 'select(.level=="ERROR")'
# 统计各级别日志数量
cat logs/oneapi.log | jq -r '.level' | sort | uniq -c
# 查找特定时间范围的日志
cat logs/oneapi.log | jq 'select(.time >= "2024-01-02 15:00:00" and .time <= "2024-01-02 16:00:00")'
```
## 📊 性能优化
1. **异步日志轮转**: 轮转操作在后台 goroutine 中执行,不阻塞主程序
2. **批量写入检查**: 每 1000 次写入才检查一次轮转条件,减少 I/O 开销
3. **读写锁**: 使用 `sync.RWMutex` 保护日志器,提高并发性能
4. **零分配**: `slog` 库在大多数情况下实现零内存分配
## 🚨 故障排查
### 日志文件未创建
- 检查日志目录是否存在且有写入权限
- 确认启动时指定了 `--log-dir` 参数
### 日志文件过多
- 调整 `LOG_MAX_FILES` 环境变量
- 手动清理不需要的旧日志文件
### 日志级别不正确
- 检查 `LOG_LEVEL` 环境变量是否正确设置
- 确认 `DEBUG` 环境变量的值(会覆盖 LOG_LEVEL
## 📖 相关文档
- [Go slog 官方文档](https://pkg.go.dev/log/slog)
- [结构化日志最佳实践](https://go.dev/blog/slog)

View File

@@ -141,6 +141,7 @@ New API提供了丰富的功能详细特性请参考[特性说明](https://do
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
- `TASK_PRICE_PATCH=sora-2-all,sora-2-pro-all`: 异步任务设置某些模型按次计费,多个模型用逗号分隔,例如`sora-2-all,sora-2-pro-all`表示sora-2-all和sora-2-pro-all模型异步任务仅按次计费不按秒等计费。
## 部署

View File

@@ -159,14 +159,15 @@ var (
GlobalWebRateLimitNum int
GlobalWebRateLimitDuration int64
CriticalRateLimitEnable bool
CriticalRateLimitNum = 20
CriticalRateLimitDuration int64 = 20 * 60
UploadRateLimitNum = 10
UploadRateLimitDuration int64 = 60
DownloadRateLimitNum = 10
DownloadRateLimitDuration int64 = 60
CriticalRateLimitNum = 20
CriticalRateLimitDuration int64 = 20 * 60
)
var RateLimitKeyExpirationDuration = 20 * time.Minute

View File

@@ -3,7 +3,7 @@ package common
import (
"flag"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"strconv"
@@ -43,9 +43,10 @@ func InitEnv() {
if os.Getenv("SESSION_SECRET") != "" {
ss := os.Getenv("SESSION_SECRET")
if ss == "random_string" {
log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
log.Println("警告SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
log.Fatal("Please set SESSION_SECRET to a random string.")
slog.Warn("SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
slog.Warn("警告SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
slog.Error("Please set SESSION_SECRET to a random string.")
os.Exit(1)
} else {
SessionSecret = ss
}
@@ -62,12 +63,14 @@ func InitEnv() {
var err error
*LogDir, err = filepath.Abs(*LogDir)
if err != nil {
log.Fatal(err)
slog.Error("failed to get absolute path for log directory", "error", err)
os.Exit(1)
}
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
err = os.Mkdir(*LogDir, 0777)
if err != nil {
log.Fatal(err)
slog.Error("failed to create log directory", "error", err)
os.Exit(1)
}
}
}
@@ -99,6 +102,9 @@ func InitEnv() {
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
initConstantEnv()
}

View File

@@ -2,6 +2,7 @@ package common
import (
"fmt"
"log/slog"
"os"
"time"
@@ -9,18 +10,16 @@ import (
)
func SysLog(s string) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
slog.Info(s, "component", "system")
}
func SysError(s string) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
slog.Error(s, "component", "system")
}
func FatalLog(v ...any) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
msg := fmt.Sprint(v...)
slog.Error(msg, "component", "system", "level", "fatal")
os.Exit(1)
}

View File

@@ -617,6 +617,10 @@ func TestAllChannels(c *gin.Context) {
var autoTestChannelsOnce sync.Once
func AutomaticallyTestChannels() {
// 只在Master节点定时测试渠道
if !common.IsMasterNode {
return
}
autoTestChannelsOnce.Do(func() {
for {
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {

View File

@@ -649,13 +649,15 @@ func DeleteDisabledChannel(c *gin.Context) {
}
type ChannelTag struct {
Tag string `json:"tag"`
NewTag *string `json:"new_tag"`
Priority *int64 `json:"priority"`
Weight *uint `json:"weight"`
ModelMapping *string `json:"model_mapping"`
Models *string `json:"models"`
Groups *string `json:"groups"`
Tag string `json:"tag"`
NewTag *string `json:"new_tag"`
Priority *int64 `json:"priority"`
Weight *uint `json:"weight"`
ModelMapping *string `json:"model_mapping"`
Models *string `json:"models"`
Groups *string `json:"groups"`
ParamOverride *string `json:"param_override"`
HeaderOverride *string `json:"header_override"`
}
func DisableTagChannels(c *gin.Context) {
@@ -721,7 +723,29 @@ func EditTagChannels(c *gin.Context) {
})
return
}
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight)
if channelTag.ParamOverride != nil {
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数覆盖必须是合法的 JSON 格式",
})
return
}
channelTag.ParamOverride = common.GetPointer[string](trimmed)
}
if channelTag.HeaderOverride != nil {
trimmed := strings.TrimSpace(*channelTag.HeaderOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请求头覆盖必须是合法的 JSON 格式",
})
return
}
channelTag.HeaderOverride = common.GetPointer[string](trimmed)
}
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight, channelTag.ParamOverride, channelTag.HeaderOverride)
if err != nil {
common.ApiError(c, err)
return

View File

@@ -510,11 +510,44 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
}
type ClaudeUsage struct {
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreation *ClaudeCacheCreationUsage `json:"cache_creation,omitempty"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
}
type ClaudeCacheCreationUsage struct {
Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"`
Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"`
}
func (u *ClaudeUsage) GetCacheCreation5mTokens() int {
if u == nil || u.CacheCreation == nil {
return 0
}
return u.CacheCreation.Ephemeral5mInputTokens
}
func (u *ClaudeUsage) GetCacheCreation1hTokens() int {
if u == nil || u.CacheCreation == nil {
return 0
}
return u.CacheCreation.Ephemeral1hInputTokens
}
func (u *ClaudeUsage) GetCacheCreationTotalTokens() int {
if u == nil {
return 0
}
if u.CacheCreationInputTokens > 0 {
return u.CacheCreationInputTokens
}
return u.GetCacheCreation5mTokens() + u.GetCacheCreation1hTokens()
}
type ClaudeServerToolUse struct {

View File

@@ -232,10 +232,13 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
return "system"
}
const CustomType = "custom"
type ToolCallRequest struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Function FunctionRequest `json:"function"`
Function FunctionRequest `json:"function,omitempty"`
Custom json.RawMessage `json:"custom,omitempty"`
}
type FunctionRequest struct {

View File

@@ -230,6 +230,11 @@ type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
// OpenRouter Params
Cost any `json:"cost,omitempty"`
}

View File

@@ -5,9 +5,11 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -19,79 +21,561 @@ import (
)
const (
loggerINFO = "INFO"
loggerWarn = "WARN"
loggerError = "ERR"
loggerDebug = "DEBUG"
// 日志轮转配置
defaultMaxLogSize = 100 * 1024 * 1024 // 100MB
defaultMaxLogFiles = 7 // 保留最近7个日志文件
defaultLogFileName = "newapi.log"
checkRotateInterval = 1000 // 每1000次写入检查一次是否需要轮转
)
const maxLogCount = 1000000
var (
logMutex sync.RWMutex
rotateCheckLock sync.Mutex
defaultLogger *slog.Logger
logFile *os.File
logFilePath string
logDirPath string
writeCount int64
maxLogSize int64 = defaultMaxLogSize
maxLogFiles int = defaultMaxLogFiles
useJSONFormat bool
)
var logCount int
var setupLogLock sync.Mutex
var setupLogWorking bool
func init() {
// Initialize with a text handler to stdout
handler := createHandler(os.Stdout)
defaultLogger = slog.New(handler)
slog.SetDefault(defaultLogger)
}
// SetupLogger 初始化日志系统
func SetupLogger() {
defer func() {
setupLogWorking = false
}()
if *common.LogDir != "" {
ok := setupLogLock.TryLock()
if !ok {
log.Println("setup log is already working")
return
logMutex.Lock()
defer logMutex.Unlock()
// 读取环境变量配置
if maxSize := os.Getenv("LOG_MAX_SIZE_MB"); maxSize != "" {
if size, err := fmt.Sscanf(maxSize, "%d", &maxLogSize); err == nil && size > 0 {
maxLogSize = maxLogSize * 1024 * 1024 // 转换为字节
}
defer func() {
setupLogLock.Unlock()
}()
logPath := filepath.Join(*common.LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405")))
fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal("failed to open log file")
}
if maxFiles := os.Getenv("LOG_MAX_FILES"); maxFiles != "" {
fmt.Sscanf(maxFiles, "%d", &maxLogFiles)
}
if os.Getenv("LOG_FORMAT") == "json" {
useJSONFormat = true
}
if *common.LogDir == "" {
// 如果没有配置日志目录,只输出到标准输出
handler := createHandler(os.Stdout)
defaultLogger = slog.New(handler)
slog.SetDefault(defaultLogger)
return
}
logDirPath = *common.LogDir
logFilePath = filepath.Join(logDirPath, defaultLogFileName)
// 检查日志文件是否需要按日期轮转(仅在启动时检查)
if err := checkAndRotateOnStartup(); err != nil {
slog.Error("failed to check log file on startup", "error", err)
}
// 打开或创建日志文件
if err := openLogFile(); err != nil {
slog.Error("failed to open log file", "error", err)
return
}
// 创建多路输出(控制台 + 文件)
multiWriter := io.MultiWriter(os.Stdout, logFile)
// 更新 gin 的默认输出
gin.DefaultWriter = multiWriter
gin.DefaultErrorWriter = multiWriter
// 更新 slog handler
handler := createHandler(multiWriter)
defaultLogger = slog.New(handler)
slog.SetDefault(defaultLogger)
slog.Info("logger initialized",
"log_dir", logDirPath,
"max_size_mb", maxLogSize/(1024*1024),
"max_files", maxLogFiles,
"format", getLogFormat())
}
// createHandler 创建日志处理器
func createHandler(w io.Writer) slog.Handler {
if useJSONFormat {
opts := &slog.HandlerOptions{
Level: getLogLevel(),
}
gin.DefaultWriter = io.MultiWriter(os.Stdout, fd)
gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd)
return slog.NewJSONHandler(w, opts)
}
return NewReadableTextHandler(w, getLogLevel())
}
// ReadableTextHandler 自定义的易读文本处理器
type ReadableTextHandler struct {
w io.Writer
level slog.Level
mu sync.Mutex
}
// NewReadableTextHandler 创建一个新的易读文本处理器
func NewReadableTextHandler(w io.Writer, level slog.Level) *ReadableTextHandler {
return &ReadableTextHandler{
w: w,
level: level,
}
}
func LogInfo(ctx context.Context, msg string) {
logHelper(ctx, loggerINFO, msg)
// Enabled 检查是否启用该级别
func (h *ReadableTextHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func LogWarn(ctx context.Context, msg string) {
logHelper(ctx, loggerWarn, msg)
// Handle 处理日志记录
func (h *ReadableTextHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
// 格式: [LEVEL] YYYY/MM/DD - HH:mm:ss | request_id | message | key=value ...
buf := make([]byte, 0, 256)
// 日志级别
level := r.Level.String()
switch r.Level {
case slog.LevelDebug:
level = "DEBUG"
case slog.LevelInfo:
level = "INFO"
case slog.LevelWarn:
level = "WARN"
case slog.LevelError:
level = "ERROR"
}
buf = append(buf, '[')
buf = append(buf, level...)
buf = append(buf, "] "...)
// 时间
buf = append(buf, r.Time.Format("2006/01/02 - 15:04:05")...)
buf = append(buf, " | "...)
// 提取 request_id 和 component
var requestID, component string
otherAttrs := make([]slog.Attr, 0)
r.Attrs(func(a slog.Attr) bool {
switch a.Key {
case "request_id":
requestID = a.Value.String()
case "component":
component = a.Value.String()
default:
otherAttrs = append(otherAttrs, a)
}
return true
})
// 输出 request_id 或 component
if requestID != "" {
buf = append(buf, requestID...)
buf = append(buf, " | "...)
} else if component != "" {
buf = append(buf, component...)
buf = append(buf, " | "...)
}
// 消息
buf = append(buf, r.Message...)
// 其他属性
if len(otherAttrs) > 0 {
buf = append(buf, " | "...)
for i, a := range otherAttrs {
if i > 0 {
buf = append(buf, ", "...)
}
buf = append(buf, a.Key...)
buf = append(buf, '=')
buf = appendValue(buf, a.Value)
}
}
buf = append(buf, '\n')
_, err := h.w.Write(buf)
return err
}
func LogError(ctx context.Context, msg string) {
logHelper(ctx, loggerError, msg)
// appendValue 追加值到缓冲区
func appendValue(buf []byte, v slog.Value) []byte {
switch v.Kind() {
case slog.KindString:
s := v.String()
// 如果字符串包含空格或特殊字符,加引号
if strings.ContainsAny(s, " \t\n\r,=") {
buf = append(buf, '"')
buf = append(buf, s...)
buf = append(buf, '"')
} else {
buf = append(buf, s...)
}
case slog.KindInt64:
buf = append(buf, fmt.Sprintf("%d", v.Int64())...)
case slog.KindUint64:
buf = append(buf, fmt.Sprintf("%d", v.Uint64())...)
case slog.KindFloat64:
buf = append(buf, fmt.Sprintf("%g", v.Float64())...)
case slog.KindBool:
buf = append(buf, fmt.Sprintf("%t", v.Bool())...)
case slog.KindDuration:
buf = append(buf, v.Duration().String()...)
case slog.KindTime:
buf = append(buf, v.Time().Format("2006-01-02 15:04:05")...)
default:
buf = append(buf, fmt.Sprintf("%v", v.Any())...)
}
return buf
}
func LogDebug(ctx context.Context, msg string, args ...any) {
msg = fmt.Sprintf(msg, args...)
// WithAttrs 返回一个新的处理器,包含指定的属性
func (h *ReadableTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
// 简化实现:不支持 With
return h
}
// WithGroup 返回一个新的处理器,使用指定的组
func (h *ReadableTextHandler) WithGroup(name string) slog.Handler {
// 简化实现:不支持组
return h
}
// checkAndRotateOnStartup 启动时检查日志文件是否需要按日期轮转
func checkAndRotateOnStartup() error {
// 检查日志文件是否存在
fileInfo, err := os.Stat(logFilePath)
if err != nil {
if os.IsNotExist(err) {
// 文件不存在,不需要轮转
return nil
}
return fmt.Errorf("failed to stat log file: %w", err)
}
// 获取文件的修改时间
modTime := fileInfo.ModTime()
modDate := modTime.Format("2006-01-02")
today := time.Now().Format("2006-01-02")
// 如果文件的日期和今天不同,进行轮转
if modDate != today {
// 生成归档文件名(使用文件的修改日期)
timestamp := modTime.Format("2006-01-02-150405")
archivePath := filepath.Join(logDirPath, fmt.Sprintf("newapi.%s.log", timestamp))
// 重命名日志文件
if err := os.Rename(logFilePath, archivePath); err != nil {
return fmt.Errorf("failed to archive old log file: %w", err)
}
slog.Info("rotated old log file on startup",
"archive", archivePath,
"reason", "date changed")
// 清理旧的日志文件
gopool.Go(func() {
cleanOldLogFiles()
})
}
return nil
}
// openLogFile 打开日志文件
func openLogFile() error {
// 关闭旧的日志文件
if logFile != nil {
logFile.Close()
}
// 打开新的日志文件
fd, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
logFile = fd
writeCount = 0
return nil
}
// rotateLogFile 轮转日志文件
func rotateLogFile() error {
if logFile == nil {
return nil
}
rotateCheckLock.Lock()
defer rotateCheckLock.Unlock()
// 获取当前日志文件信息
fileInfo, err := logFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat log file: %w", err)
}
// 检查文件大小是否需要轮转
if fileInfo.Size() < maxLogSize {
return nil
}
// 关闭当前日志文件
logFile.Close()
// 生成归档文件名
timestamp := time.Now().Format("2006-01-02-150405")
archivePath := filepath.Join(logDirPath, fmt.Sprintf("newapi.%s.log", timestamp))
// 重命名当前日志文件为归档文件
if err := os.Rename(logFilePath, archivePath); err != nil {
// 如果重命名失败,尝试复制
if copyErr := copyFile(logFilePath, archivePath); copyErr != nil {
return fmt.Errorf("failed to archive log file: %w", err)
}
os.Truncate(logFilePath, 0)
}
// 清理旧的日志文件
gopool.Go(func() {
cleanOldLogFiles()
})
// 打开新的日志文件
if err := openLogFile(); err != nil {
return err
}
// 重新设置日志输出
multiWriter := io.MultiWriter(os.Stdout, logFile)
gin.DefaultWriter = multiWriter
gin.DefaultErrorWriter = multiWriter
handler := createHandler(multiWriter)
logMutex.Lock()
defaultLogger = slog.New(handler)
slog.SetDefault(defaultLogger)
logMutex.Unlock()
slog.Info("log file rotated",
"reason", "size limit reached",
"archive", archivePath)
return nil
}
// cleanOldLogFiles 清理旧的日志文件
func cleanOldLogFiles() {
if logDirPath == "" {
return
}
files, err := os.ReadDir(logDirPath)
if err != nil {
slog.Error("failed to read log directory", "error", err)
return
}
// 收集所有归档日志文件
var logFiles []os.DirEntry
for _, file := range files {
if !file.IsDir() && strings.HasPrefix(file.Name(), "newapi.") &&
strings.HasSuffix(file.Name(), ".log") &&
file.Name() != defaultLogFileName {
logFiles = append(logFiles, file)
}
}
// 如果归档文件数量超过限制,删除最旧的
if len(logFiles) > maxLogFiles {
// 按名称排序(文件名包含时间戳)
sort.Slice(logFiles, func(i, j int) bool {
return logFiles[i].Name() < logFiles[j].Name()
})
// 删除最旧的文件
deleteCount := len(logFiles) - maxLogFiles
for i := 0; i < deleteCount; i++ {
filePath := filepath.Join(logDirPath, logFiles[i].Name())
if err := os.Remove(filePath); err != nil {
slog.Error("failed to remove old log file",
"file", filePath,
"error", err)
} else {
slog.Info("removed old log file", "file", logFiles[i].Name())
}
}
}
}
// copyFile 复制文件
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
return err
}
// getLogLevel 获取日志级别
func getLogLevel() slog.Level {
// 支持环境变量配置
if level := os.Getenv("LOG_LEVEL"); level != "" {
switch strings.ToUpper(level) {
case "DEBUG":
return slog.LevelDebug
case "INFO":
return slog.LevelInfo
case "WARN", "WARNING":
return slog.LevelWarn
case "ERROR":
return slog.LevelError
}
}
if common.DebugEnabled {
logHelper(ctx, loggerDebug, msg)
return slog.LevelDebug
}
return slog.LevelInfo
}
// getLogFormat 获取日志格式
func getLogFormat() string {
if useJSONFormat {
return "json"
}
return "text"
}
// checkAndRotateLog 检查并轮转日志
func checkAndRotateLog() {
if logFile == nil {
return
}
writeCount++
if writeCount%checkRotateInterval == 0 {
gopool.Go(func() {
if err := rotateLogFile(); err != nil {
slog.Error("failed to rotate log file", "error", err)
}
})
}
}
func logHelper(ctx context.Context, level string, msg string) {
writer := gin.DefaultErrorWriter
if level == loggerINFO {
writer = gin.DefaultWriter
// LogInfo 记录信息级别日志
func LogInfo(ctx context.Context, msg string) {
if ctx == nil {
ctx = context.Background()
}
id := getRequestID(ctx)
logMutex.RLock()
logger := defaultLogger
logMutex.RUnlock()
logger.InfoContext(ctx, msg, "request_id", id)
checkAndRotateLog()
}
// LogWarn 记录警告级别日志
func LogWarn(ctx context.Context, msg string) {
if ctx == nil {
ctx = context.Background()
}
id := getRequestID(ctx)
logMutex.RLock()
logger := defaultLogger
logMutex.RUnlock()
logger.WarnContext(ctx, msg, "request_id", id)
checkAndRotateLog()
}
// LogError 记录错误级别日志
func LogError(ctx context.Context, msg string) {
if ctx == nil {
ctx = context.Background()
}
id := getRequestID(ctx)
logMutex.RLock()
logger := defaultLogger
logMutex.RUnlock()
logger.ErrorContext(ctx, msg, "request_id", id)
checkAndRotateLog()
}
// LogSystemInfo 记录系统信息
func LogSystemInfo(msg string) {
logMutex.RLock()
logger := defaultLogger
logMutex.RUnlock()
logger.Info(msg, "request_id", "SYSTEM")
checkAndRotateLog()
}
// LogSystemError 记录系统错误
func LogSystemError(msg string) {
logMutex.RLock()
logger := defaultLogger
logMutex.RUnlock()
logger.Error(msg, "request_id", "SYSTEM")
checkAndRotateLog()
}
// LogDebug 记录调试级别日志
func LogDebug(ctx context.Context, msg string, args ...any) {
if !common.DebugEnabled && getLogLevel() > slog.LevelDebug {
return
}
if ctx == nil {
ctx = context.Background()
}
id := getRequestID(ctx)
if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
logMutex.RLock()
logger := defaultLogger
logMutex.RUnlock()
logger.DebugContext(ctx, msg, "request_id", id)
checkAndRotateLog()
}
// getRequestID 从上下文中获取请求ID
func getRequestID(ctx context.Context) string {
if ctx == nil {
return "SYSTEM"
}
id := ctx.Value(common.RequestIdKey)
if id == nil {
id = "SYSTEM"
return "SYSTEM"
}
now := time.Now()
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)
logCount++ // we don't need accurate count, so no lock here
if logCount > maxLogCount && !setupLogWorking {
logCount = 0
setupLogWorking = true
gopool.Go(func() {
SetupLogger()
})
if strID, ok := id.(string); ok {
return strID
}
return "SYSTEM"
}
func LogQuota(quota int) string {

View File

@@ -4,7 +4,7 @@ import (
"bytes"
"embed"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"strconv"
@@ -118,7 +118,9 @@ func main() {
if os.Getenv("ENABLE_PPROF") == "true" {
gopool.Go(func() {
log.Println(http.ListenAndServe("0.0.0.0:8005", nil))
if err := http.ListenAndServe("0.0.0.0:8005", nil); err != nil {
slog.Error("pprof server failed", "error", err)
}
})
go common.Monitor()
common.SysLog("pprof enabled")
@@ -127,7 +129,7 @@ func main() {
// Initialize HTTP server
server := gin.New()
server.Use(gin.CustomRecovery(func(c *gin.Context, err any) {
common.SysLog(fmt.Sprintf("panic detected: %v", err))
logger.LogSystemError(fmt.Sprintf("panic detected: %v", err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),

View File

@@ -102,7 +102,10 @@ func GlobalAPIRateLimit() func(c *gin.Context) {
}
func CriticalRateLimit() func(c *gin.Context) {
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
if common.CriticalRateLimitEnable {
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
}
return defNext
}
func DownloadRateLimit() func(c *gin.Context) {

View File

@@ -688,7 +688,7 @@ func DisableChannelByTag(tag string) error {
return err
}
func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint) error {
func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint, paramOverride *string, headerOverride *string) error {
updateData := Channel{}
shouldReCreateAbilities := false
updatedTag := tag
@@ -714,6 +714,12 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
if weight != nil {
updateData.Weight = weight
}
if paramOverride != nil {
updateData.ParamOverride = paramOverride
}
if headerOverride != nil {
updateData.HeaderOverride = headerOverride
}
err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error
if err != nil {

View File

@@ -98,9 +98,9 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque
return nil, errors.New("image is required")
}
if len(imageFiles) > 1 {
return nil, errors.New("only one image is supported for qwen edit")
}
//if len(imageFiles) > 1 {
// return nil, errors.New("only one image is supported for qwen edit")
//}
// 获取base64编码的图片
var imageBase64s []string

View File

@@ -596,6 +596,8 @@ func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeRespons
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta.Text != nil {
@@ -740,6 +742,8 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
}
var responseData []byte
switch info.RelayFormat {

View File

@@ -122,6 +122,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
var usage = &dto.Usage{}
var streamItems []string // store stream items
var lastStreamData string
var secondLastStreamData string // 存储倒数第二个stream data用于音频模型
// 检查是否为音频模型
isAudioModel := strings.Contains(strings.ToLower(model), "audio")
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
if lastStreamData != "" {
@@ -131,12 +135,35 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
}
if len(data) > 0 {
// 对音频模型保存倒数第二个stream data
if isAudioModel && lastStreamData != "" {
secondLastStreamData = lastStreamData
}
lastStreamData = data
streamItems = append(streamItems, data)
}
return true
})
// 对音频模型从倒数第二个stream data中提取usage信息
if isAudioModel && secondLastStreamData != "" {
var streamResp struct {
Usage *dto.Usage `json:"usage"`
}
err := json.Unmarshal([]byte(secondLastStreamData), &streamResp)
if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) {
usage = streamResp.Usage
containStreamUsage = true
if common.DebugEnabled {
logger.LogDebug(c, fmt.Sprintf("Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
usage.InputTokens, usage.OutputTokens))
}
}
}
// 处理最后的响应
shouldSendLastResp := true
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,

View File

@@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
@@ -155,8 +156,51 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
return bytes.NewReader(bodyBytes), nil
}
func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq) (*AliVideoRequest, error) {
otherRatios := map[string]map[string]float64{
var (
size480p = []string{
"832*480",
"480*832",
"624*624",
}
size720p = []string{
"1280*720",
"720*1280",
"960*960",
"1088*832",
"832*1088",
}
size1080p = []string{
"1920*1080",
"1080*1920",
"1440*1440",
"1632*1248",
"1248*1632",
}
)
func sizeToResolution(size string) (string, error) {
if lo.Contains(size480p, size) {
return "480P", nil
} else if lo.Contains(size720p, size) {
return "720P", nil
} else if lo.Contains(size1080p, size) {
return "1080P", nil
}
return "", fmt.Errorf("invalid size: %s", size)
}
func ProcessAliOtherRatios(aliReq *AliVideoRequest) (map[string]float64, error) {
otherRatios := make(map[string]float64)
aliRatios := map[string]map[string]float64{
"wan2.5-t2v-preview": {
"480P": 1,
"720P": 2,
"1080P": 1 / 0.3,
},
"wan2.2-t2v-plus": {
"480P": 1,
"1080P": 0.7 / 0.14,
},
"wan2.5-i2v-preview": {
"480P": 1,
"720P": 2,
@@ -180,6 +224,30 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay
"720P": 0.9 / 0.5,
},
}
var resolution string
// size match
if aliReq.Parameters.Size != "" {
toResolution, err := sizeToResolution(aliReq.Parameters.Size)
if err != nil {
return nil, err
}
resolution = toResolution
} else {
resolution = strings.ToUpper(aliReq.Parameters.Resolution)
if !strings.HasSuffix(resolution, "P") {
resolution = resolution + "P"
}
}
if otherRatio, ok := aliRatios[aliReq.Model]; ok {
if ratio, ok := otherRatio[resolution]; ok {
otherRatios[fmt.Sprintf("resolution-%s", resolution)] = ratio
}
}
return otherRatios, nil
}
func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq) (*AliVideoRequest, error) {
aliReq := &AliVideoRequest{
Model: req.Model,
Input: AliVideoInput{
@@ -194,22 +262,40 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay
// 处理分辨率映射
if req.Size != "" {
resolution := strings.ToUpper(req.Size)
// 支持 480p, 720p, 1080p 或 480P, 720P, 1080P
if !strings.HasSuffix(resolution, "P") {
resolution = resolution + "P"
// text to video size must be contained *
if strings.Contains(req.Model, "t2v") && !strings.Contains(req.Size, "*") {
return nil, fmt.Errorf("invalid size: %s, example: %s", req.Size, "1920*1080")
}
if strings.Contains(req.Size, "*") {
aliReq.Parameters.Size = req.Size
} else {
resolution := strings.ToUpper(req.Size)
// 支持 480p, 720p, 1080p 或 480P, 720P, 1080P
if !strings.HasSuffix(resolution, "P") {
resolution = resolution + "P"
}
aliReq.Parameters.Resolution = resolution
}
aliReq.Parameters.Resolution = resolution
} else {
// 根据模型设置默认分辨率
if strings.HasPrefix(req.Model, "wan2.5") {
aliReq.Parameters.Resolution = "1080P"
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-flash") {
aliReq.Parameters.Resolution = "720P"
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-plus") {
aliReq.Parameters.Resolution = "1080P"
if strings.Contains(req.Model, "t2v") { // image to video
if strings.HasPrefix(req.Model, "wan2.5") {
aliReq.Parameters.Size = "1920*1080"
} else if strings.HasPrefix(req.Model, "wan2.2") {
aliReq.Parameters.Size = "1920*1080"
} else {
aliReq.Parameters.Size = "1280*720"
}
} else {
aliReq.Parameters.Resolution = "720P"
if strings.HasPrefix(req.Model, "wan2.5") {
aliReq.Parameters.Resolution = "1080P"
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-flash") {
aliReq.Parameters.Resolution = "720P"
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-plus") {
aliReq.Parameters.Resolution = "1080P"
} else {
aliReq.Parameters.Resolution = "720P"
}
}
}
@@ -247,13 +333,13 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay
"seconds": float64(aliReq.Parameters.Duration),
}
if otherRatio, ok := otherRatios[req.Model]; ok {
if ratio, ok := otherRatio[aliReq.Parameters.Resolution]; ok {
info.PriceData.OtherRatios[fmt.Sprintf("resolution-%s", aliReq.Parameters.Resolution)] = ratio
}
ratios, err := ProcessAliOtherRatios(aliReq)
if err != nil {
return nil, err
}
for s, f := range ratios {
info.PriceData.OtherRatios[s] = f
}
// println(fmt.Sprintf("other ratios: %v", info.PriceData.OtherRatios))
return aliReq, nil
}

View File

@@ -406,12 +406,15 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
// 即梦视频3.0 ReqKey转换
// https://www.volcengine.com/docs/85621/1792707
if strings.Contains(r.ReqKey, "jimeng_v30") {
if len(req.Images) > 1 {
if r.ReqKey == "jimeng_v30_pro" {
// 3.0 pro只有固定的jimeng_ti2v_v30_pro
r.ReqKey = "jimeng_ti2v_v30_pro"
} else if len(req.Images) > 1 {
// 多张图片:首尾帧生成
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1)
r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1), "p")
} else if len(req.Images) == 1 {
// 单张图片:图生视频
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1)
r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1), "p")
} else {
// 无图片:文生视频
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)

View File

@@ -13,6 +13,9 @@ import (
"github.com/gin-gonic/gin"
)
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
const claudeCacheCreation1hMultiplier = 6 / 3.75
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present
func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.GroupRatioInfo {
groupRatioInfo := types.GroupRatioInfo{
@@ -53,6 +56,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var cacheRatio float64
var imageRatio float64
var cacheCreationRatio float64
var cacheCreationRatio5m float64
var cacheCreationRatio1h float64
var audioRatio float64
var audioCompletionRatio float64
var freeModel bool
@@ -76,6 +81,9 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
cacheCreationRatio5m = cacheCreationRatio
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
@@ -116,6 +124,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
AudioRatio: audioRatio,
AudioCompletionRatio: audioCompletionRatio,
CacheCreationRatio: cacheCreationRatio,
CacheCreation5mRatio: cacheCreationRatio5m,
CacheCreation1hRatio: cacheCreationRatio1h,
QuotaToPreConsume: preConsumedQuota,
}

View File

@@ -92,11 +92,23 @@ func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
cacheTokens int, cacheRatio float64,
cacheCreationTokens int, cacheCreationRatio float64,
cacheCreationTokens5m int, cacheCreationRatio5m float64,
cacheCreationTokens1h int, cacheCreationRatio1h float64,
modelPrice float64, userGroupRatio float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
info["claude"] = true
info["cache_creation_tokens"] = cacheCreationTokens
info["cache_creation_ratio"] = cacheCreationRatio
if cacheCreationTokens5m != 0 {
info["cache_creation_tokens_5m"] = cacheCreationTokens5m
info["cache_creation_ratio_5m"] = cacheCreationRatio5m
}
if cacheCreationTokens1h != 0 {
info["cache_creation_tokens_1h"] = cacheCreationTokens1h
info["cache_creation_ratio_1h"] = cacheCreationRatio1h
}
return info
}

View File

@@ -251,7 +251,11 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
cacheTokens := usage.PromptTokensDetails.CachedTokens
cacheCreationRatio := relayInfo.PriceData.CacheCreationRatio
cacheCreationRatio5m := relayInfo.PriceData.CacheCreation5mRatio
cacheCreationRatio1h := relayInfo.PriceData.CacheCreation1hRatio
cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
cacheCreationTokens5m := usage.ClaudeCacheCreation5mTokens
cacheCreationTokens1h := usage.ClaudeCacheCreation1hTokens
if relayInfo.ChannelType == constant.ChannelTypeOpenRouter {
promptTokens -= cacheTokens
@@ -269,7 +273,12 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
if !relayInfo.PriceData.UsePrice {
calculateQuota = float64(promptTokens)
calculateQuota += float64(cacheTokens) * cacheRatio
calculateQuota += float64(cacheCreationTokens) * cacheCreationRatio
calculateQuota += float64(cacheCreationTokens5m) * cacheCreationRatio5m
calculateQuota += float64(cacheCreationTokens1h) * cacheCreationRatio1h
remainingCacheCreationTokens := cacheCreationTokens - cacheCreationTokens5m - cacheCreationTokens1h
if remainingCacheCreationTokens > 0 {
calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio
}
calculateQuota += float64(completionTokens) * completionRatio
calculateQuota = calculateQuota * groupRatio * modelRatio
} else {
@@ -322,7 +331,11 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
}
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
cacheTokens, cacheRatio,
cacheCreationTokens, cacheCreationRatio,
cacheCreationTokens5m, cacheCreationRatio5m,
cacheCreationTokens1h, cacheCreationRatio1h,
modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,

View File

@@ -15,6 +15,8 @@ type PriceData struct {
CompletionRatio float64
CacheRatio float64
CacheCreationRatio float64
CacheCreation5mRatio float64
CacheCreation1hRatio float64
ImageRatio float64
AudioRatio float64
AudioCompletionRatio float64
@@ -31,5 +33,5 @@ type PerCallPriceData struct {
}
func (p PriceData) ToSetting() string {
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
}

View File

@@ -45,6 +45,7 @@ import {
IconBookmark,
IconUser,
IconCode,
IconSetting,
} from '@douyinfe/semi-icons';
import { getChannelModels } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
@@ -69,6 +70,8 @@ const EditTagModal = (props) => {
model_mapping: null,
groups: [],
models: [],
param_override: null,
header_override: null,
};
const [inputs, setInputs] = useState(originInputs);
const formApiRef = useRef(null);
@@ -190,12 +193,48 @@ const EditTagModal = (props) => {
if (formVals.models && formVals.models.length > 0) {
data.models = formVals.models.join(',');
}
if (
formVals.param_override !== undefined &&
formVals.param_override !== null
) {
if (typeof formVals.param_override !== 'string') {
showInfo('参数覆盖必须是合法的 JSON 格式!');
setLoading(false);
return;
}
const trimmedParamOverride = formVals.param_override.trim();
if (trimmedParamOverride !== '' && !verifyJSON(trimmedParamOverride)) {
showInfo('参数覆盖必须是合法的 JSON 格式!');
setLoading(false);
return;
}
data.param_override = trimmedParamOverride;
}
if (
formVals.header_override !== undefined &&
formVals.header_override !== null
) {
if (typeof formVals.header_override !== 'string') {
showInfo('请求头覆盖必须是合法的 JSON 格式!');
setLoading(false);
return;
}
const trimmedHeaderOverride = formVals.header_override.trim();
if (trimmedHeaderOverride !== '' && !verifyJSON(trimmedHeaderOverride)) {
showInfo('请求头覆盖必须是合法的 JSON 格式!');
setLoading(false);
return;
}
data.header_override = trimmedHeaderOverride;
}
data.new_tag = formVals.new_tag;
if (
data.model_mapping === undefined &&
data.groups === undefined &&
data.models === undefined &&
data.new_tag === undefined
data.new_tag === undefined &&
data.param_override === undefined &&
data.header_override === undefined
) {
showWarning('没有任何修改!');
setLoading(false);
@@ -491,6 +530,157 @@ const EditTagModal = (props) => {
</div>
</Card>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
<IconSetting size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('高级设置')}</Text>
<div className='text-xs text-gray-600'>
{t('渠道的高级配置选项')}
</div>
</div>
</div>
<div className='space-y-4'>
<Form.TextArea
field='param_override'
label={t('参数覆盖')}
placeholder={
t(
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
) +
'\n' +
t('旧格式(直接覆盖):') +
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
'\n\n' +
t('新格式支持条件判断与json自定义') +
'\n{\n "operations": [\n {\n "path": "temperature",\n "mode": "set",\n "value": 0.7,\n "conditions": [\n {\n "path": "model",\n "mode": "prefix",\n "value": "gpt"\n }\n ]\n }\n ]\n}'
}
autosize
showClear
onChange={(value) =>
handleInputChange('param_override', value)
}
extraText={
<div className='flex gap-2 flex-wrap'>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'param_override',
JSON.stringify({ temperature: 0 }, null, 2),
)
}
>
{t('旧格式模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'param_override',
JSON.stringify(
{
operations: [
{
path: 'temperature',
mode: 'set',
value: 0.7,
conditions: [
{
path: 'model',
mode: 'prefix',
value: 'gpt',
},
],
logic: 'AND',
},
],
},
null,
2,
),
)
}
>
{t('新格式模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange('param_override', null)
}
>
{t('不更改')}
</Text>
</div>
}
/>
<Form.TextArea
field='header_override'
label={t('请求头覆盖')}
placeholder={
t('此项可选,用于覆盖请求头参数') +
'\n' +
t('格式示例:') +
'\n{\n "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n "Authorization": "Bearer {api_key}"\n}'
}
autosize
showClear
onChange={(value) =>
handleInputChange('header_override', value)
}
extraText={
<div className='flex flex-col gap-1'>
<div className='flex gap-2 flex-wrap items-center'>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange(
'header_override',
JSON.stringify(
{
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
Authorization: 'Bearer {api_key}',
},
null,
2,
),
)
}
>
{t('填入模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() =>
handleInputChange('header_override', null)
}
>
{t('不更改')}
</Text>
</div>
<div>
<Text type='tertiary' size='small'>
{t('支持变量:')}
</Text>
<div className='text-xs text-tertiary ml-2'>
<div>
{t('渠道密钥')}: {'{api_key}'}
</div>
</div>
</div>
</div>
}
/>
</div>
</Card>
<Card className='!rounded-2xl shadow-sm border-0'>
{/* Header: Group Settings */}
<div className='flex items-center mb-2'>

View File

@@ -66,9 +66,9 @@ const EditTokenModal = (props) => {
const getInitValues = () => ({
name: '',
remain_quota: 500000,
remain_quota: 0,
expired_time: -1,
unlimited_quota: false,
unlimited_quota: true,
model_limits_enabled: false,
model_limits: [],
allow_ips: '',

View File

@@ -551,6 +551,10 @@ export const getLogsColumns = ({
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
false,
1.0,
other?.is_system_prompt_overwritten,
@@ -565,6 +569,10 @@ export const getLogsColumns = ({
other.cache_ratio || 1.0,
0,
1.0,
0,
1.0,
0,
1.0,
false,
1.0,
other?.is_system_prompt_overwritten,

56
web/src/helpers/base64.js Normal file
View File

@@ -0,0 +1,56 @@
/*
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
*/
const toBinaryString = (text) => {
if (typeof TextEncoder !== 'undefined') {
const bytes = new TextEncoder().encode(text);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return binary;
}
return encodeURIComponent(text).replace(/%([0-9A-F]{2})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16)),
);
};
export const encodeToBase64 = (value) => {
const input = value == null ? '' : String(value);
if (typeof window === 'undefined') {
if (typeof Buffer !== 'undefined') {
return Buffer.from(input, 'utf-8').toString('base64');
}
if (
typeof globalThis !== 'undefined' &&
typeof globalThis.btoa === 'function'
) {
return globalThis.btoa(toBinaryString(input));
}
throw new Error(
'Base64 encoding is unavailable in the current environment',
);
}
return window.btoa(toBinaryString(input));
};

View File

@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
export * from './history';
export * from './auth';
export * from './utils';
export * from './base64';
export * from './api';
export * from './render';
export * from './log';

View File

@@ -1046,6 +1046,10 @@ function renderPriceSimpleCore({
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
image = false,
imageRatio = 1.0,
isSystemPromptOverride = false,
@@ -1064,17 +1068,40 @@ function renderPriceSimpleCore({
});
}
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowLegacyCacheCreation =
!hasSplitCacheCreation && cacheCreationTokens !== 0;
const shouldShowCache = cacheTokens !== 0;
const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0;
const parts = [];
// base: model ratio
parts.push(i18next.t('模型: {{ratio}}'));
// cache part (label differs when with image)
if (cacheTokens !== 0) {
if (shouldShowCache) {
parts.push(i18next.t('缓存: {{cacheRatio}}'));
}
// cache creation part (Claude specific if passed)
if (cacheCreationTokens !== 0) {
if (hasSplitCacheCreation) {
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
parts.push(
i18next.t(
'缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
),
);
} else if (shouldShowCacheCreation5m) {
parts.push(i18next.t('缓存创建: 5m {{cacheCreationRatio5m}}'));
} else if (shouldShowCacheCreation1h) {
parts.push(i18next.t('缓存创建: 1h {{cacheCreationRatio1h}}'));
}
} else if (shouldShowLegacyCacheCreation) {
parts.push(i18next.t('缓存创建: {{cacheCreationRatio}}'));
}
@@ -1091,6 +1118,8 @@ function renderPriceSimpleCore({
groupRatio: finalGroupRatio,
cacheRatio: cacheRatio,
cacheCreationRatio: cacheCreationRatio,
cacheCreationRatio5m: cacheCreationRatio5m,
cacheCreationRatio1h: cacheCreationRatio1h,
imageRatio: imageRatio,
});
@@ -1450,6 +1479,10 @@ export function renderModelPriceSimple(
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
image = false,
imageRatio = 1.0,
isSystemPromptOverride = false,
@@ -1464,6 +1497,10 @@ export function renderModelPriceSimple(
cacheRatio,
cacheCreationTokens,
cacheCreationRatio,
cacheCreationTokens5m,
cacheCreationRatio5m,
cacheCreationTokens1h,
cacheCreationRatio1h,
image,
imageRatio,
isSystemPromptOverride,
@@ -1681,6 +1718,10 @@ export function renderClaudeModelPrice(
cacheRatio = 1.0,
cacheCreationTokens = 0,
cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1710,20 +1751,121 @@ export function renderClaudeModelPrice(
const completionRatioValue = completionRatio || 0;
const inputRatioPrice = modelRatio * 2.0;
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
let cacheRatioPrice = (modelRatio * 2.0 * cacheRatio).toFixed(2);
let cacheCreationRatioPrice = modelRatio * 2.0 * cacheCreationRatio;
const cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
const cacheCreationRatioPrice = modelRatio * 2.0 * cacheCreationRatio;
const cacheCreationRatioPrice5m = modelRatio * 2.0 * cacheCreationRatio5m;
const cacheCreationRatioPrice1h = modelRatio * 2.0 * cacheCreationRatio1h;
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowCache = cacheTokens > 0;
const shouldShowLegacyCacheCreation =
!hasSplitCacheCreation && cacheCreationTokens > 0;
const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0;
// Calculate effective input tokens (non-cached + cached with ratio applied + cache creation with ratio applied)
const nonCachedTokens = inputTokens;
const effectiveInputTokens =
nonCachedTokens +
cacheTokens * cacheRatio +
cacheCreationTokens * cacheCreationRatio;
cacheCreationTokens * cacheCreationRatio +
cacheCreationTokens5m * cacheCreationRatio5m +
cacheCreationTokens1h * cacheCreationRatio1h;
let price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
const inputUnitPrice = inputRatioPrice * rate;
const completionUnitPrice = completionRatioPrice * rate;
const cacheUnitPrice = cacheRatioPrice * rate;
const cacheCreationUnitPrice = cacheCreationRatioPrice * rate;
const cacheCreationUnitPrice5m = cacheCreationRatioPrice5m * rate;
const cacheCreationUnitPrice1h = cacheCreationRatioPrice1h * rate;
const cacheCreationUnitPriceTotal =
cacheCreationUnitPrice5m + cacheCreationUnitPrice1h;
const breakdownSegments = [
i18next.t('提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}}', {
input: inputTokens,
symbol,
price: inputUnitPrice.toFixed(6),
}),
];
if (shouldShowCache) {
breakdownSegments.push(
i18next.t(
'缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}} (倍率: {{ratio}})',
{
tokens: cacheTokens,
symbol,
price: cacheUnitPrice.toFixed(6),
ratio: cacheRatio,
},
),
);
}
if (shouldShowLegacyCacheCreation) {
breakdownSegments.push(
i18next.t(
'缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}} (倍率: {{ratio}})',
{
tokens: cacheCreationTokens,
symbol,
price: cacheCreationUnitPrice.toFixed(6),
ratio: cacheCreationRatio,
},
),
);
}
if (shouldShowCacheCreation5m) {
breakdownSegments.push(
i18next.t(
'5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}} (倍率: {{ratio}})',
{
tokens: cacheCreationTokens5m,
symbol,
price: cacheCreationUnitPrice5m.toFixed(6),
ratio: cacheCreationRatio5m,
},
),
);
}
if (shouldShowCacheCreation1h) {
breakdownSegments.push(
i18next.t(
'1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}} (倍率: {{ratio}})',
{
tokens: cacheCreationTokens1h,
symbol,
price: cacheCreationUnitPrice1h.toFixed(6),
ratio: cacheCreationRatio1h,
},
),
);
}
breakdownSegments.push(
i18next.t(
'补全 {{completion}} tokens / 1M tokens * {{symbol}}{{price}}',
{
completion: completionTokens,
symbol,
price: completionUnitPrice.toFixed(6),
},
),
);
const breakdownText = breakdownSegments.join(' + ');
return (
<>
<article>
@@ -1744,7 +1886,7 @@ export function renderClaudeModelPrice(
},
)}
</p>
{cacheTokens > 0 && (
{shouldShowCache && (
<p>
{i18next.t(
'缓存价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
@@ -1752,13 +1894,13 @@ export function renderClaudeModelPrice(
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
ratio: cacheRatio,
total: (cacheRatioPrice * rate).toFixed(2),
total: cacheUnitPrice.toFixed(6),
cacheRatio: cacheRatio,
},
)}
</p>
)}
{cacheCreationTokens > 0 && (
{shouldShowLegacyCacheCreation && (
<p>
{i18next.t(
'缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
@@ -1766,49 +1908,65 @@ export function renderClaudeModelPrice(
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
ratio: cacheCreationRatio,
total: (cacheCreationRatioPrice * rate).toFixed(6),
total: cacheCreationUnitPrice.toFixed(6),
cacheCreationRatio: cacheCreationRatio,
},
)}
</p>
)}
{shouldShowCacheCreation5m && (
<p>
{i18next.t(
'5m缓存创建价格{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (5m缓存创建倍率: {{cacheCreationRatio5m}})',
{
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
ratio: cacheCreationRatio5m,
total: cacheCreationUnitPrice5m.toFixed(6),
cacheCreationRatio5m: cacheCreationRatio5m,
},
)}
</p>
)}
{shouldShowCacheCreation1h && (
<p>
{i18next.t(
'1h缓存创建价格{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (1h缓存创建倍率: {{cacheCreationRatio1h}})',
{
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
ratio: cacheCreationRatio1h,
total: cacheCreationUnitPrice1h.toFixed(6),
cacheCreationRatio1h: cacheCreationRatio1h,
},
)}
</p>
)}
{shouldShowCacheCreation5m && shouldShowCacheCreation1h && (
<p>
{i18next.t(
'缓存创建价格合计5m {{symbol}}{{five}} + 1h {{symbol}}{{one}} = {{symbol}}{{total}} / 1M tokens',
{
symbol: symbol,
five: cacheCreationUnitPrice5m.toFixed(6),
one: cacheCreationUnitPrice1h.toFixed(6),
total: cacheCreationUnitPriceTotal.toFixed(6),
},
)}
</p>
)}
<p></p>
<p>
{cacheTokens > 0 || cacheCreationTokens > 0
? i18next.t(
'提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
symbol: symbol,
cachePrice: (cacheRatioPrice * rate).toFixed(2),
cacheCreationPrice: (
cacheCreationRatioPrice * rate
).toFixed(6),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
total: (price * rate).toFixed(6),
},
)
: i18next.t(
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
input: inputTokens,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
total: (price * rate).toFixed(6),
},
)}
{i18next.t(
'{{breakdown}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
{
breakdown: breakdownText,
ratioType: ratioLabel,
ratio: groupRatio,
symbol: symbol,
total: (price * rate).toFixed(6),
},
)}
</p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article>
@@ -1825,6 +1983,10 @@ export function renderClaudeLogContent(
user_group_ratio,
cacheRatio = 1.0,
cacheCreationRatio = 1.0,
cacheCreationTokens5m = 0,
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1843,17 +2005,58 @@ export function renderClaudeLogContent(
ratio: groupRatio,
});
} else {
return i18next.t(
'模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},缓存创建倍率 {{cacheCreationRatio}}{{ratioType}} {{ratio}}',
{
modelRatio: modelRatio,
completionRatio: completionRatio,
cacheRatio: cacheRatio,
cacheCreationRatio: cacheCreationRatio,
const hasSplitCacheCreation =
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
const shouldShowCacheCreation5m =
hasSplitCacheCreation && cacheCreationTokens5m > 0;
const shouldShowCacheCreation1h =
hasSplitCacheCreation && cacheCreationTokens1h > 0;
let cacheCreationPart = null;
if (hasSplitCacheCreation) {
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
cacheCreationPart = i18next.t(
'缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
{
cacheCreationRatio5m,
cacheCreationRatio1h,
},
);
} else if (shouldShowCacheCreation5m) {
cacheCreationPart = i18next.t(
'缓存创建倍率 5m {{cacheCreationRatio5m}}',
{
cacheCreationRatio5m,
},
);
} else if (shouldShowCacheCreation1h) {
cacheCreationPart = i18next.t(
'缓存创建倍率 1h {{cacheCreationRatio1h}}',
{
cacheCreationRatio1h,
},
);
}
}
if (!cacheCreationPart) {
cacheCreationPart = i18next.t('缓存创建倍率 {{cacheCreationRatio}}', {
cacheCreationRatio,
});
}
const parts = [
i18next.t('模型倍率 {{modelRatio}}', { modelRatio }),
i18next.t('输出倍率 {{completionRatio}}', { completionRatio }),
i18next.t('缓存倍率 {{cacheRatio}}', { cacheRatio }),
cacheCreationPart,
i18next.t('{{ratioType}} {{ratio}}', {
ratioType: ratioLabel,
ratio: groupRatio,
},
);
}),
];
return parts.join('');
}
}

View File

@@ -20,7 +20,13 @@ For commercial licensing, please contact support@quantumnous.com
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui';
import { API, copy, showError, showSuccess } from '../../helpers';
import {
API,
copy,
showError,
showSuccess,
encodeToBase64,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
@@ -136,7 +142,7 @@ export const useTokensData = (openFluentNotification) => {
apiKey: 'sk-' + record.key,
};
let encodedConfig = encodeURIComponent(
btoa(JSON.stringify(cherryConfig)),
encodeToBase64(JSON.stringify(cherryConfig)),
);
url = url.replaceAll('{cherryConfig}', encodedConfig);
} else {

View File

@@ -361,6 +361,10 @@ export const useLogsData = () => {
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
)
: renderLogContent(
other?.model_ratio,
@@ -429,6 +433,10 @@ export const useLogsData = () => {
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
);
} else {
content = renderModelPrice(

View File

@@ -1516,6 +1516,10 @@
"缓存倍率": "Cache ratio",
"缓存创建 Tokens": "Cache Creation Tokens",
"缓存创建: {{cacheCreationRatio}}": "Cache creation: {{cacheCreationRatio}}",
"缓存创建: 5m {{cacheCreationRatio5m}}": "Cache creation: 5m {{cacheCreationRatio5m}}",
"缓存创建: 1h {{cacheCreationRatio1h}}": "Cache creation: 1h {{cacheCreationRatio1h}}",
"缓存创建倍率 5m {{cacheCreationRatio5m}}": "Cache creation multiplier 5m {{cacheCreationRatio5m}}",
"缓存创建倍率 1h {{cacheCreationRatio1h}}": "Cache creation multiplier 1h {{cacheCreationRatio1h}}",
"缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})": "Cache creation price: {{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (Cache creation ratio: {{cacheCreationRatio}})",
"编辑": "Edit",
"编辑API": "Edit API",
@@ -2104,4 +2108,4 @@
"统一的": "The Unified",
"大模型接口网关": "LLM API Gateway"
}
}
}

View File

@@ -1525,6 +1525,10 @@
"缓存倍率": "Ratio de cache",
"缓存创建 Tokens": "Jetons de création de cache",
"缓存创建: {{cacheCreationRatio}}": "Création de cache : {{cacheCreationRatio}}",
"缓存创建: 5m {{cacheCreationRatio5m}}": "Création de cache : 5m {{cacheCreationRatio5m}}",
"缓存创建: 1h {{cacheCreationRatio1h}}": "Création de cache : 1h {{cacheCreationRatio1h}}",
"缓存创建倍率 5m {{cacheCreationRatio5m}}": "Multiplicateur de création de cache 5m {{cacheCreationRatio5m}}",
"缓存创建倍率 1h {{cacheCreationRatio1h}}": "Multiplicateur de création de cache 1h {{cacheCreationRatio1h}}",
"缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})": "Prix de création du cache : {{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (taux de création de cache : {{cacheCreationRatio}})",
"编辑": "Modifier",
"编辑API": "Modifier l'API",

View File

@@ -1516,6 +1516,10 @@
"缓存倍率": "キャッシュ倍率",
"缓存创建 Tokens": "キャッシュ作成トークン",
"缓存创建: {{cacheCreationRatio}}": "キャッシュ作成:{{cacheCreationRatio}}",
"缓存创建: 5m {{cacheCreationRatio5m}}": "キャッシュ作成5m {{cacheCreationRatio5m}}",
"缓存创建: 1h {{cacheCreationRatio1h}}": "キャッシュ作成1h {{cacheCreationRatio1h}}",
"缓存创建倍率 5m {{cacheCreationRatio5m}}": "キャッシュ作成倍率 5m {{cacheCreationRatio5m}}",
"缓存创建倍率 1h {{cacheCreationRatio1h}}": "キャッシュ作成倍率 1h {{cacheCreationRatio1h}}",
"缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})": "キャッシュ作成料金:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1Mtokensキャッシュ作成倍率{{cacheCreationRatio}}",
"编辑": "編集",
"编辑API": "API編集",
@@ -2075,4 +2079,4 @@
"统一的": "統合型",
"大模型接口网关": "LLM APIゲートウェイ"
}
}
}

View File

@@ -1534,6 +1534,10 @@
"缓存倍率": "Коэффициент кэширования",
"缓存创建 Tokens": "Создание кэша токенов",
"缓存创建: {{cacheCreationRatio}}": "Создание кэша: {{cacheCreationRatio}}",
"缓存创建: 5m {{cacheCreationRatio5m}}": "Создание кэша: 5m {{cacheCreationRatio5m}}",
"缓存创建: 1h {{cacheCreationRatio1h}}": "Создание кэша: 1h {{cacheCreationRatio1h}}",
"缓存创建倍率 5m {{cacheCreationRatio5m}}": "Множитель создания кэша 5m {{cacheCreationRatio5m}}",
"缓存创建倍率 1h {{cacheCreationRatio1h}}": "Множитель создания кэша 1h {{cacheCreationRatio1h}}",
"缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})": "Цена создания кэша: {{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M токенов (коэффициент создания кэша: {{cacheCreationRatio}})",
"编辑": "Редактировать",
"编辑API": "Редактировать API",

View File

@@ -1507,6 +1507,10 @@
"缓存倍率": "缓存倍率",
"缓存创建 Tokens": "缓存创建 Tokens",
"缓存创建: {{cacheCreationRatio}}": "缓存创建: {{cacheCreationRatio}}",
"缓存创建: 5m {{cacheCreationRatio5m}}": "缓存创建: 5m {{cacheCreationRatio5m}}",
"缓存创建: 1h {{cacheCreationRatio1h}}": "缓存创建: 1h {{cacheCreationRatio1h}}",
"缓存创建倍率 5m {{cacheCreationRatio5m}}": "缓存创建倍率 5m {{cacheCreationRatio5m}}",
"缓存创建倍率 1h {{cacheCreationRatio1h}}": "缓存创建倍率 1h {{cacheCreationRatio1h}}",
"缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})": "缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})",
"编辑": "编辑",
"编辑API": "编辑API",
@@ -2066,4 +2070,4 @@
"Creem 介绍": "Creem 是一个简单的支付处理平台,支持固定金额产品销售,以及订阅销售。",
"Creem Setting Tips": "Creem 只支持预设的固定金额产品这产品以及价格需要提前在Creem网站内创建配置所以不支持自定义动态金额充值。在Creem端配置产品的名字以及价格获取Product Id 后填到下面的产品在new-api为该产品设置充值额度以及展示价格。"
}
}
}

View File

@@ -47,6 +47,7 @@ import {
createLoadingAssistantMessage,
getTextContent,
buildApiPayload,
encodeToBase64,
} from '../../helpers';
// Components
@@ -72,7 +73,7 @@ const generateAvatarDataUrl = (username) => {
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="16" fill="#ffffff" font-family="sans-serif">${firstLetter}</text>
</svg>
`;
return `data:image/svg+xml;base64,${btoa(svg)}`;
return `data:image/svg+xml;base64,${encodeToBase64(svg)}`;
};
const Playground = () => {