mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-31 08:05:24 +00:00
Compare commits
19 Commits
v0.9.15-pa
...
logger
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13ff448049 | ||
|
|
3dc4d6c39e | ||
|
|
019412c27a | ||
|
|
96a2b81aaa | ||
|
|
fb610e62a0 | ||
|
|
736f7b55b7 | ||
|
|
2fd33ea294 | ||
|
|
53123aaf94 | ||
|
|
f8f5d26600 | ||
|
|
c86bc94d9d | ||
|
|
50e8639a40 | ||
|
|
424325162e | ||
|
|
a9a8676f7c | ||
|
|
14295f0035 | ||
|
|
29e70acc55 | ||
|
|
8599b348c0 | ||
|
|
6a761c2dba | ||
|
|
df2ee649ab | ||
|
|
00782aae88 |
260
LOGGING.md
Normal file
260
LOGGING.md
Normal 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
|
||||
|
||||
# 日志级别(可选,默认: INFO,DEBUG 模式除外)
|
||||
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)
|
||||
|
||||
@@ -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模型异步任务仅按次计费,不按秒等计费。
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
582
logger/logger.go
582
logger/logger.go
@@ -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 {
|
||||
|
||||
8
main.go
8
main.go
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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
56
web/src/helpers/base64.js
Normal 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));
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ゲートウェイ"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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为该产品设置充值额度,以及展示价格。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user