diff --git a/constant/channel.go b/constant/channel.go
index 34fb20f4..7d8893c1 100644
--- a/constant/channel.go
+++ b/constant/channel.go
@@ -51,9 +51,9 @@ const (
ChannelTypeJimeng = 51
ChannelTypeVidu = 52
ChannelTypeSubmodel = 53
+ ChannelTypeDoubaoVideo = 54
ChannelTypeDummy // this one is only for count, do not add any channel after this
-
)
var ChannelBaseURLs = []string{
@@ -111,4 +111,5 @@ var ChannelBaseURLs = []string{
"https://visual.volcengineapi.com", //51
"https://api.vidu.cn", //52
"https://llm.submodel.ai", //53
+ "https://ark.cn-beijing.volces.com", //54
}
diff --git a/controller/channel-test.go b/controller/channel-test.go
index b3a3be4e..ff1e8cef 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -70,6 +70,12 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
newAPIError: nil,
}
}
+ if channel.Type == constant.ChannelTypeDoubaoVideo {
+ return testResult{
+ localErr: errors.New("doubao video channel test is not supported"),
+ newAPIError: nil,
+ }
+ }
if channel.Type == constant.ChannelTypeVidu {
return testResult{
localErr: errors.New("vidu channel test is not supported"),
diff --git a/controller/task_video.go b/controller/task_video.go
index 73d5c39b..ded011fe 100644
--- a/controller/task_video.go
+++ b/controller/task_video.go
@@ -13,6 +13,7 @@ import (
"one-api/relay"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
+ "one-api/setting/ratio_setting"
"time"
)
@@ -120,6 +121,91 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
task.FailReason = taskResult.Url
}
+
+ // 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
+ if taskResult.TotalTokens > 0 {
+ // 获取模型名称
+ var taskData map[string]interface{}
+ if err := json.Unmarshal(task.Data, &taskData); err == nil {
+ if modelName, ok := taskData["model"].(string); ok && modelName != "" {
+ // 获取模型价格和倍率
+ modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
+
+ // 只有配置了倍率(非固定价格)时才按 token 重新计费
+ if hasRatioSetting && modelRatio > 0 {
+ // 获取用户和组的倍率信息
+ user, err := model.GetUserById(task.UserId, false)
+ if err == nil {
+ groupRatio := ratio_setting.GetGroupRatio(user.Group)
+ userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group)
+
+ var finalGroupRatio float64
+ if hasUserGroupRatio {
+ finalGroupRatio = userGroupRatio
+ } else {
+ finalGroupRatio = groupRatio
+ }
+
+ // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
+ actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
+
+ // 计算差额
+ preConsumedQuota := task.Quota
+ quotaDelta := actualQuota - preConsumedQuota
+
+ if quotaDelta > 0 {
+ // 需要补扣费
+ logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
+ task.TaskID,
+ logger.LogQuota(quotaDelta),
+ logger.LogQuota(actualQuota),
+ logger.LogQuota(preConsumedQuota),
+ taskResult.TotalTokens,
+ ))
+ if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil {
+ logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
+ } else {
+ model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
+ model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
+ task.Quota = actualQuota // 更新任务记录的实际扣费额度
+
+ // 记录消费日志
+ logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
+ modelRatio, finalGroupRatio, taskResult.TotalTokens,
+ logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
+ model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
+ }
+ } else if quotaDelta < 0 {
+ // 需要退还多扣的费用
+ refundQuota := -quotaDelta
+ logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
+ task.TaskID,
+ logger.LogQuota(refundQuota),
+ logger.LogQuota(actualQuota),
+ logger.LogQuota(preConsumedQuota),
+ taskResult.TotalTokens,
+ ))
+ if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
+ logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
+ } else {
+ task.Quota = actualQuota // 更新任务记录的实际扣费额度
+
+ // 记录退款日志
+ logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
+ modelRatio, finalGroupRatio, taskResult.TotalTokens,
+ logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
+ model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
+ }
+ } else {
+ // quotaDelta == 0, 预扣费刚好准确
+ logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)",
+ task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
+ }
+ }
+ }
+ }
+ }
+ }
case model.TaskStatusFailure:
task.Status = model.TaskStatusFailure
task.Progress = "100%"
diff --git a/controller/user.go b/controller/user.go
index c03afa32..33d4636b 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct {
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
BarkUrl string `json:"bark_url,omitempty"`
+ GotifyUrl string `json:"gotify_url,omitempty"`
+ GotifyToken string `json:"gotify_token,omitempty"`
+ GotifyPriority int `json:"gotify_priority,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
@@ -1117,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 验证预警类型
- if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
+ if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的预警类型",
@@ -1192,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) {
}
}
+ // 如果是Gotify类型,验证Gotify URL和Token
+ if req.QuotaWarningType == dto.NotifyTypeGotify {
+ if req.GotifyUrl == "" {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "Gotify服务器地址不能为空",
+ })
+ return
+ }
+ if req.GotifyToken == "" {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "Gotify令牌不能为空",
+ })
+ return
+ }
+ // 验证URL格式
+ if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "无效的Gotify服务器地址",
+ })
+ return
+ }
+ // 检查是否是HTTP或HTTPS
+ if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "Gotify服务器地址必须以http://或https://开头",
+ })
+ return
+ }
+ }
+
userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
@@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) {
settings.BarkUrl = req.BarkUrl
}
+ // 如果是Gotify类型,添加Gotify配置到设置中
+ if req.QuotaWarningType == dto.NotifyTypeGotify {
+ settings.GotifyUrl = req.GotifyUrl
+ settings.GotifyToken = req.GotifyToken
+ // Gotify优先级范围0-10,超出范围则使用默认值5
+ if req.GotifyPriority < 0 || req.GotifyPriority > 10 {
+ settings.GotifyPriority = 5
+ } else {
+ settings.GotifyPriority = req.GotifyPriority
+ }
+ }
+
// 更新用户设置
user.SetSetting(settings)
if err := user.Update(false); err != nil {
diff --git a/docker-compose.yml b/docker-compose.yml
index d98fd706..b98776d1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,17 @@
+# New-API Docker Compose Configuration
+#
+# Quick Start:
+# 1. docker-compose up -d
+# 2. Access at http://localhost:3000
+#
+# Using MySQL instead of PostgreSQL:
+# 1. Comment out the postgres service and SQL_DSN line 15
+# 2. Uncomment the mysql service and SQL_DSN line 16
+# 3. Uncomment mysql in depends_on (line 28)
+# 4. Uncomment mysql_data in volumes section (line 64)
+#
+# ⚠️ IMPORTANT: Change all default passwords before deploying to production!
+
version: '3.4'
services:
@@ -12,21 +26,22 @@ services:
- ./data:/data
- ./logs:/app/logs
environment:
- - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service
+ - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
+# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
- REDIS_CONN_STRING=redis://redis
- TZ=Asia/Shanghai
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录
- # - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值
- # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!!
- # - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment
- # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
- # - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL
+ - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 batch update enabled
+# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
+# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!!
+# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
depends_on:
- redis
- - mysql
+ - postgres
+# - mysql # Uncomment if using MySQL
healthcheck:
- test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"]
+ test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -36,17 +51,31 @@ services:
container_name: redis
restart: always
- mysql:
- image: mysql:8.2
- container_name: mysql
+ postgres:
+ image: postgres:15
+ container_name: postgres
restart: always
environment:
- MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN
- MYSQL_DATABASE: new-api
+ POSTGRES_USER: root
+ POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
+ POSTGRES_DB: new-api
volumes:
- - mysql_data:/var/lib/mysql
- # ports:
- # - "3306:3306" # If you want to access MySQL from outside Docker, uncomment
+ - pg_data:/var/lib/postgresql/data
+# ports:
+# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker
+
+# mysql:
+# image: mysql:8.2
+# container_name: mysql
+# restart: always
+# environment:
+# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
+# MYSQL_DATABASE: new-api
+# volumes:
+# - mysql_data:/var/lib/mysql
+# ports:
+# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker
volumes:
- mysql_data:
+ pg_data:
+# mysql_data:
diff --git a/dto/channel_settings.go b/dto/channel_settings.go
index d6d6e084..d57184b3 100644
--- a/dto/channel_settings.go
+++ b/dto/channel_settings.go
@@ -20,6 +20,9 @@ type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
+ AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
+ DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
+ AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
diff --git a/dto/claude.go b/dto/claude.go
index 42774226..dfc5cfd4 100644
--- a/dto/claude.go
+++ b/dto/claude.go
@@ -195,12 +195,15 @@ type ClaudeRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
- //ClaudeMetadata `json:"metadata,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools any `json:"tools,omitempty"`
ContextManagement json.RawMessage `json:"context_management,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
+ McpServers json.RawMessage `json:"mcp_servers,omitempty"`
+ Metadata json.RawMessage `json:"metadata,omitempty"`
+ // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
+ ServiceTier string `json:"service_tier,omitempty"`
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
diff --git a/dto/openai_request.go b/dto/openai_request.go
index 191fa638..dbdfad44 100644
--- a/dto/openai_request.go
+++ b/dto/openai_request.go
@@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct {
Dimensions int `json:"dimensions,omitempty"`
Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"`
+ // 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户
+ // 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私
+ SafetyIdentifier string `json:"safety_identifier,omitempty"`
+ // Whether or not to store the output of this chat completion request for use in our model distillation or evals products.
+ // 是否存储此次请求数据供 OpenAI 用于评估和优化产品
+ // 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用
+ Store json.RawMessage `json:"store,omitempty"`
+ // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field
+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
+ LogitBias json.RawMessage `json:"logit_bias,omitempty"`
+ Metadata json.RawMessage `json:"metadata,omitempty"`
+ Prediction json.RawMessage `json:"prediction,omitempty"`
// gemini
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
//xai
@@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct {
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
- ServiceTier string `json:"service_tier,omitempty"`
- Store json.RawMessage `json:"store,omitempty"`
- PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
- Stream bool `json:"stream,omitempty"`
- Temperature float64 `json:"temperature,omitempty"`
- Text json.RawMessage `json:"text,omitempty"`
- ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
- Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
- TopP float64 `json:"top_p,omitempty"`
- Truncation string `json:"truncation,omitempty"`
- User string `json:"user,omitempty"`
- MaxToolCalls uint `json:"max_tool_calls,omitempty"`
- Prompt json.RawMessage `json:"prompt,omitempty"`
+ // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
+ ServiceTier string `json:"service_tier,omitempty"`
+ Store json.RawMessage `json:"store,omitempty"`
+ PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
+ Stream bool `json:"stream,omitempty"`
+ Temperature float64 `json:"temperature,omitempty"`
+ Text json.RawMessage `json:"text,omitempty"`
+ ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
+ Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
+ TopP float64 `json:"top_p,omitempty"`
+ Truncation string `json:"truncation,omitempty"`
+ User string `json:"user,omitempty"`
+ MaxToolCalls uint `json:"max_tool_calls,omitempty"`
+ Prompt json.RawMessage `json:"prompt,omitempty"`
}
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
diff --git a/dto/user_settings.go b/dto/user_settings.go
index 89dd926e..16ce7b98 100644
--- a/dto/user_settings.go
+++ b/dto/user_settings.go
@@ -7,6 +7,9 @@ type UserSetting struct {
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
+ GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址
+ GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
+ GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
@@ -16,4 +19,5 @@ var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
+ NotifyTypeGotify = "gotify" // Gotify 推送
)
diff --git a/middleware/distributor.go b/middleware/distributor.go
index 7fefeda4..3d929df4 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -169,6 +169,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
err = common.UnmarshalBodyReusable(c, &modelRequest)
+ if err != nil {
+ return nil, false, errors.New("video无效的请求, " + err.Error())
+ }
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go
index 6202c9fc..92d60df4 100644
--- a/relay/channel/aws/adaptor.go
+++ b/relay/channel/aws/adaptor.go
@@ -7,7 +7,6 @@ import (
"one-api/dto"
"one-api/relay/channel/claude"
relaycommon "one-api/relay/common"
- "one-api/setting/model_setting"
"one-api/types"
"github.com/gin-gonic/gin"
@@ -52,11 +51,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
- anthropicBeta := c.Request.Header.Get("anthropic-beta")
- if anthropicBeta != "" {
- req.Set("anthropic-beta", anthropicBeta)
- }
- model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
+ claude.CommonClaudeHeadersOperation(c, req, info)
return nil
}
diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go
index 362f09e7..17e7cbd2 100644
--- a/relay/channel/claude/adaptor.go
+++ b/relay/channel/claude/adaptor.go
@@ -64,6 +64,15 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return baseURL, nil
}
+func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
+ // common headers operation
+ anthropicBeta := c.Request.Header.Get("anthropic-beta")
+ if anthropicBeta != "" {
+ req.Set("anthropic-beta", anthropicBeta)
+ }
+ model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
+}
+
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("x-api-key", info.ApiKey)
@@ -72,11 +81,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
anthropicVersion = "2023-06-01"
}
req.Set("anthropic-version", anthropicVersion)
- anthropicBeta := c.Request.Header.Get("anthropic-beta")
- if anthropicBeta != "" {
- req.Set("anthropic-beta", anthropicBeta)
- }
- model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
+ CommonClaudeHeadersOperation(c, req, info)
return nil
}
diff --git a/relay/channel/jina/adaptor.go b/relay/channel/jina/adaptor.go
index a383728f..dbfe314d 100644
--- a/relay/channel/jina/adaptor.go
+++ b/relay/channel/jina/adaptor.go
@@ -76,6 +76,7 @@ func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dt
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
+ request.EncodingFormat = ""
return request, nil
}
diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go
index 85938a77..7b148f32 100644
--- a/relay/channel/openai/relay_responses.go
+++ b/relay/channel/openai/relay_responses.go
@@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
if streamResponse.Item != nil {
switch streamResponse.Item.Type {
case dto.BuildInCallWebSearchCall:
- info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++
+ if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil {
+ if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil {
+ webSearchTool.CallCount++
+ }
+ }
}
}
}
diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go
new file mode 100644
index 00000000..8cc1fa4f
--- /dev/null
+++ b/relay/channel/task/doubao/adaptor.go
@@ -0,0 +1,248 @@
+package doubao
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "one-api/constant"
+ "one-api/dto"
+ "one-api/model"
+ "one-api/relay/channel"
+ relaycommon "one-api/relay/common"
+ "one-api/service"
+
+ "github.com/gin-gonic/gin"
+ "github.com/pkg/errors"
+)
+
+// ============================
+// Request / Response structures
+// ============================
+
+type ContentItem struct {
+ Type string `json:"type"` // "text" or "image_url"
+ Text string `json:"text,omitempty"` // for text type
+ ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
+}
+
+type ImageURL struct {
+ URL string `json:"url"`
+}
+
+type requestPayload struct {
+ Model string `json:"model"`
+ Content []ContentItem `json:"content"`
+}
+
+type responsePayload struct {
+ ID string `json:"id"` // task_id
+}
+
+type responseTask struct {
+ ID string `json:"id"`
+ Model string `json:"model"`
+ Status string `json:"status"`
+ Content struct {
+ VideoURL string `json:"video_url"`
+ } `json:"content"`
+ Seed int `json:"seed"`
+ Resolution string `json:"resolution"`
+ Duration int `json:"duration"`
+ Ratio string `json:"ratio"`
+ FramesPerSecond int `json:"framespersecond"`
+ Usage struct {
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
+
+// ============================
+// Adaptor implementation
+// ============================
+
+type TaskAdaptor struct {
+ ChannelType int
+ apiKey string
+ baseURL string
+}
+
+func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
+ a.ChannelType = info.ChannelType
+ a.baseURL = info.ChannelBaseUrl
+ a.apiKey = info.ApiKey
+}
+
+// ValidateRequestAndSetAction parses body, validates fields and sets default action.
+func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
+ // Accept only POST /v1/video/generations as "generate" action.
+ return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
+}
+
+// BuildRequestURL constructs the upstream URL.
+func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
+ return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
+}
+
+// BuildRequestHeader sets required headers.
+func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Authorization", "Bearer "+a.apiKey)
+ return nil
+}
+
+// BuildRequestBody converts request into Doubao specific format.
+func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
+ v, exists := c.Get("task_request")
+ if !exists {
+ return nil, fmt.Errorf("request not found in context")
+ }
+ req := v.(relaycommon.TaskSubmitReq)
+
+ body, err := a.convertToRequestPayload(&req)
+ if err != nil {
+ return nil, errors.Wrap(err, "convert request payload failed")
+ }
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bytes.NewReader(data), nil
+}
+
+// DoRequest delegates to common helper.
+func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
+ return channel.DoTaskApiRequest(a, c, info, requestBody)
+}
+
+// DoResponse handles upstream response, returns taskID etc.
+func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
+ return
+ }
+ _ = resp.Body.Close()
+
+ // Parse Doubao response
+ var dResp responsePayload
+ if err := json.Unmarshal(responseBody, &dResp); err != nil {
+ taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
+ return
+ }
+
+ if dResp.ID == "" {
+ taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
+ return dResp.ID, responseBody, nil
+}
+
+// FetchTask fetch task status
+func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
+ taskID, ok := body["task_id"].(string)
+ if !ok {
+ return nil, fmt.Errorf("invalid task_id")
+ }
+
+ uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID)
+
+ req, err := http.NewRequest(http.MethodGet, uri, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+key)
+
+ return service.GetHttpClient().Do(req)
+}
+
+func (a *TaskAdaptor) GetModelList() []string {
+ return ModelList
+}
+
+func (a *TaskAdaptor) GetChannelName() string {
+ return ChannelName
+}
+
+func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
+ r := requestPayload{
+ Model: req.Model,
+ Content: []ContentItem{},
+ }
+
+ // Add text prompt
+ if req.Prompt != "" {
+ r.Content = append(r.Content, ContentItem{
+ Type: "text",
+ Text: req.Prompt,
+ })
+ }
+
+ // Add images if present
+ if req.HasImage() {
+ for _, imgURL := range req.Images {
+ r.Content = append(r.Content, ContentItem{
+ Type: "image_url",
+ ImageURL: &ImageURL{
+ URL: imgURL,
+ },
+ })
+ }
+ }
+
+ // TODO: Add support for additional parameters from metadata
+ // such as ratio, duration, seed, etc.
+ // metadata := req.Metadata
+ // if metadata != nil {
+ // // Parse and apply metadata parameters
+ // }
+
+ return &r, nil
+}
+
+func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
+ resTask := responseTask{}
+ if err := json.Unmarshal(respBody, &resTask); err != nil {
+ return nil, errors.Wrap(err, "unmarshal task result failed")
+ }
+
+ taskResult := relaycommon.TaskInfo{
+ Code: 0,
+ }
+
+ // Map Doubao status to internal status
+ switch resTask.Status {
+ case "pending", "queued":
+ taskResult.Status = model.TaskStatusQueued
+ taskResult.Progress = "10%"
+ case "processing":
+ taskResult.Status = model.TaskStatusInProgress
+ taskResult.Progress = "50%"
+ case "succeeded":
+ taskResult.Status = model.TaskStatusSuccess
+ taskResult.Progress = "100%"
+ taskResult.Url = resTask.Content.VideoURL
+ // 解析 usage 信息用于按倍率计费
+ taskResult.CompletionTokens = resTask.Usage.CompletionTokens
+ taskResult.TotalTokens = resTask.Usage.TotalTokens
+ case "failed":
+ taskResult.Status = model.TaskStatusFailure
+ taskResult.Progress = "100%"
+ taskResult.Reason = "task failed"
+ default:
+ // Unknown status, treat as processing
+ taskResult.Status = model.TaskStatusInProgress
+ taskResult.Progress = "30%"
+ }
+
+ return &taskResult, nil
+}
diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go
new file mode 100644
index 00000000..74b416c6
--- /dev/null
+++ b/relay/channel/task/doubao/constants.go
@@ -0,0 +1,9 @@
+package doubao
+
+var ModelList = []string{
+ "doubao-seedance-1-0-pro-250528",
+ "doubao-seedance-1-0-lite-t2v",
+ "doubao-seedance-1-0-lite-i2v",
+}
+
+var ChannelName = "doubao-video"
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index 91a7f88c..c4781813 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -91,7 +91,43 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
}
a.AccountCredentials = *adc
- if a.RequestMode == RequestModeLlama {
+ if a.RequestMode == RequestModeGemini {
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
+ adc.ProjectID,
+ modelName,
+ suffix,
+ ), nil
+ } else {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
+ region,
+ adc.ProjectID,
+ region,
+ modelName,
+ suffix,
+ ), nil
+ }
+ } else if a.RequestMode == RequestModeClaude {
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
+ adc.ProjectID,
+ modelName,
+ suffix,
+ ), nil
+ } else {
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
+ region,
+ adc.ProjectID,
+ region,
+ modelName,
+ suffix,
+ ), nil
+ }
+ } else if a.RequestMode == RequestModeLlama {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
region,
@@ -99,42 +135,33 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
region,
), nil
}
-
- if region == "global" {
- return fmt.Sprintf(
- "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
- adc.ProjectID,
- modelName,
- suffix,
- ), nil
- } else {
- return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
- region,
- adc.ProjectID,
- region,
- modelName,
- suffix,
- ), nil
- }
} else {
+ var keyPrefix string
+ if strings.HasSuffix(suffix, "?alt=sse") {
+ keyPrefix = "&"
+ } else {
+ keyPrefix = "?"
+ }
if region == "global" {
return fmt.Sprintf(
- "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
+ "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
modelName,
suffix,
+ keyPrefix,
info.ApiKey,
), nil
} else {
return fmt.Sprintf(
- "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
+ "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
region,
modelName,
suffix,
+ keyPrefix,
info.ApiKey,
), nil
}
}
+ return "", errors.New("unsupported request mode")
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -188,7 +215,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
req.Set("Authorization", "Bearer "+accessToken)
}
- if a.AccountCredentials.ProjectID != "" {
+ if a.AccountCredentials.ProjectID != "" {
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
}
return nil
diff --git a/relay/claude_handler.go b/relay/claude_handler.go
index 59d12abe..3a739785 100644
--- a/relay/claude_handler.go
+++ b/relay/claude_handler.go
@@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
+ // remove disabled fields for Claude API
+ jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ }
+
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go
index f4ffaee2..cc860abd 100644
--- a/relay/common/relay_info.go
+++ b/relay/common/relay_info.go
@@ -500,10 +500,52 @@ func (t TaskSubmitReq) HasImage() bool {
}
type TaskInfo struct {
- Code int `json:"code"`
- TaskID string `json:"task_id"`
- Status string `json:"status"`
- Reason string `json:"reason,omitempty"`
- Url string `json:"url,omitempty"`
- Progress string `json:"progress,omitempty"`
+ Code int `json:"code"`
+ TaskID string `json:"task_id"`
+ Status string `json:"status"`
+ Reason string `json:"reason,omitempty"`
+ Url string `json:"url,omitempty"`
+ Progress string `json:"progress,omitempty"`
+ CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费
+ TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
+}
+
+// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
+// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持)
+// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
+// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
+func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) {
+ var data map[string]interface{}
+ if err := common.Unmarshal(jsonData, &data); err != nil {
+ common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error())
+ return jsonData, nil
+ }
+
+ // 默认移除 service_tier,除非明确允许(避免额外计费风险)
+ if !channelOtherSettings.AllowServiceTier {
+ if _, exists := data["service_tier"]; exists {
+ delete(data, "service_tier")
+ }
+ }
+
+ // 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
+ if channelOtherSettings.DisableStore {
+ if _, exists := data["store"]; exists {
+ delete(data, "store")
+ }
+ }
+
+ // 默认移除 safety_identifier,除非明确允许(保护用户隐私,避免向 OpenAI 报告用户信息)
+ if !channelOtherSettings.AllowSafetyIdentifier {
+ if _, exists := data["safety_identifier"]; exists {
+ delete(data, "safety_identifier")
+ }
+ }
+
+ jsonDataAfter, err := common.Marshal(data)
+ if err != nil {
+ common.SysError("RemoveDisabledFields Marshal error :" + err.Error())
+ return jsonData, nil
+ }
+ return jsonDataAfter, nil
}
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 38b820f7..a3ddf6d4 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
}
+ // remove disabled fields for OpenAI API
+ jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ }
+
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go
index 406074c5..c8fd51a1 100644
--- a/relay/relay_adaptor.go
+++ b/relay/relay_adaptor.go
@@ -1,6 +1,7 @@
package relay
import (
+ "github.com/gin-gonic/gin"
"one-api/constant"
"one-api/relay/channel"
"one-api/relay/channel/ali"
@@ -24,6 +25,8 @@ import (
"one-api/relay/channel/palm"
"one-api/relay/channel/perplexity"
"one-api/relay/channel/siliconflow"
+ "one-api/relay/channel/submodel"
+ taskdoubao "one-api/relay/channel/task/doubao"
taskjimeng "one-api/relay/channel/task/jimeng"
"one-api/relay/channel/task/kling"
"one-api/relay/channel/task/suno"
@@ -37,8 +40,6 @@ import (
"one-api/relay/channel/zhipu"
"one-api/relay/channel/zhipu_4v"
"strconv"
- "one-api/relay/channel/submodel"
- "github.com/gin-gonic/gin"
)
func GetAdaptor(apiType int) channel.Adaptor {
@@ -134,6 +135,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
return &taskvertex.TaskAdaptor{}
case constant.ChannelTypeVidu:
return &taskVidu.TaskAdaptor{}
+ case constant.ChannelTypeDoubaoVideo:
+ return &taskdoubao.TaskAdaptor{}
}
}
return nil
diff --git a/relay/responses_handler.go b/relay/responses_handler.go
index 0c57a303..6958f96e 100644
--- a/relay/responses_handler.go
+++ b/relay/responses_handler.go
@@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
+
+ // remove disabled fields for OpenAI Responses API
+ jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
+ if err != nil {
+ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
+ }
+
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
diff --git a/service/quota.go b/service/quota.go
index 12017e11..43c4024a 100644
--- a/service/quota.go
+++ b/service/quota.go
@@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
// Bark推送使用简短文本,不支持HTML
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
+ } else if notifyType == dto.NotifyTypeGotify {
+ content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
+ values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else {
- // 默认内容格式,适用于Email和Webhook
+ // 默认内容格式,适用于Email和Webhook(支持HTML)
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
}
diff --git a/service/user_notify.go b/service/user_notify.go
index fba12d9d..0f92e7d7 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -1,6 +1,8 @@
package service
import (
+ "bytes"
+ "encoding/json"
"fmt"
"net/http"
"net/url"
@@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
switch notifyType {
case dto.NotifyTypeEmail:
- // check setting email
- userEmail = userSetting.NotificationEmail
- if userEmail == "" {
+ // 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱
+ emailToUse := userSetting.NotificationEmail
+ if emailToUse == "" {
+ emailToUse = userEmail
+ }
+ if emailToUse == "" {
common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId))
return nil
}
- return sendEmailNotify(userEmail, data)
+ return sendEmailNotify(emailToUse, data)
case dto.NotifyTypeWebhook:
webhookURLStr := userSetting.WebhookUrl
if webhookURLStr == "" {
@@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
return nil
}
return sendBarkNotify(barkURL, data)
+ case dto.NotifyTypeGotify:
+ gotifyUrl := userSetting.GotifyUrl
+ gotifyToken := userSetting.GotifyToken
+ if gotifyUrl == "" || gotifyToken == "" {
+ common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId))
+ return nil
+ }
+ return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data)
}
return nil
}
@@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
return nil
}
+
+func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error {
+ // 处理占位符
+ content := data.Content
+ for _, value := range data.Values {
+ content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
+ }
+
+ // 构建完整的 Gotify API URL
+ // 确保 URL 以 /message 结尾
+ finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken)
+
+ // Gotify优先级范围0-10,如果超出范围则使用默认值5
+ if priority < 0 || priority > 10 {
+ priority = 5
+ }
+
+ // 构建 JSON payload
+ type GotifyMessage struct {
+ Title string `json:"title"`
+ Message string `json:"message"`
+ Priority int `json:"priority"`
+ }
+
+ payload := GotifyMessage{
+ Title: data.Title,
+ Message: content,
+ Priority: priority,
+ }
+
+ // 序列化为 JSON
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("failed to marshal gotify payload: %v", err)
+ }
+
+ var req *http.Request
+ var resp *http.Response
+
+ if system_setting.EnableWorker() {
+ // 使用worker发送请求
+ workerReq := &WorkerRequest{
+ URL: finalURL,
+ Key: system_setting.WorkerValidKey,
+ Method: http.MethodPost,
+ Headers: map[string]string{
+ "Content-Type": "application/json; charset=utf-8",
+ "User-Agent": "OneAPI-Gotify-Notify/1.0",
+ },
+ Body: payloadBytes,
+ }
+
+ resp, err = DoWorkerRequest(workerReq)
+ if err != nil {
+ return fmt.Errorf("failed to send gotify request through worker: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // 检查响应状态
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
+ }
+ } else {
+ // SSRF防护:验证Gotify URL(非Worker模式)
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
+ return fmt.Errorf("request reject: %v", err)
+ }
+
+ // 直接发送请求
+ req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes))
+ if err != nil {
+ return fmt.Errorf("failed to create gotify request: %v", err)
+ }
+
+ // 设置请求头
+ req.Header.Set("Content-Type", "application/json; charset=utf-8")
+ req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0")
+
+ // 发送请求
+ client := GetHttpClient()
+ resp, err = client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to send gotify request: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // 检查响应状态
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
+ }
+ }
+
+ return nil
+}
diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go
index 5b89d6fe..adb76bfc 100644
--- a/setting/operation_setting/tools.go
+++ b/setting/operation_setting/tools.go
@@ -29,6 +29,7 @@ const (
Gemini25FlashLitePreviewInputAudioPrice = 0.50
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
+ GeminiRoboticsER15InputAudioPrice = 1.00
)
const (
@@ -74,6 +75,8 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
return Gemini25FlashProductionInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
+ } else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
+ return GeminiRoboticsER15InputAudioPrice
}
return 0
}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 0c073718..5e55576f 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -179,6 +179,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
+ "gemini-robotics-er-1.5-preview": 0.15,
"gemini-embedding-001": 0.075,
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
@@ -252,17 +253,17 @@ var defaultModelRatio = map[string]float64{
"grok-vision-beta": 2.5,
"grok-3-fast-beta": 2.5,
"grok-3-mini-fast-beta": 0.3,
- // submodel
- "NousResearch/Hermes-4-405B-FP8": 0.8,
- "Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
- "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
- "Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3,
- "zai-org/GLM-4.5-FP8": 0.8,
- "openai/gpt-oss-120b": 0.5,
- "deepseek-ai/DeepSeek-R1-0528": 0.8,
- "deepseek-ai/DeepSeek-R1": 0.8,
- "deepseek-ai/DeepSeek-V3-0324": 0.8,
- "deepseek-ai/DeepSeek-V3.1": 0.8,
+ // submodel
+ "NousResearch/Hermes-4-405B-FP8": 0.8,
+ "Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
+ "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
+ "Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3,
+ "zai-org/GLM-4.5-FP8": 0.8,
+ "openai/gpt-oss-120b": 0.5,
+ "deepseek-ai/DeepSeek-R1-0528": 0.8,
+ "deepseek-ai/DeepSeek-R1": 0.8,
+ "deepseek-ai/DeepSeek-V3-0324": 0.8,
+ "deepseek-ai/DeepSeek-V3.1": 0.8,
}
var defaultModelPrice = map[string]float64{
@@ -587,6 +588,8 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 4, false
}
return 2.5 / 0.3, false
+ } else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
+ return 2.5 / 0.3, false
}
return 4, false
}
diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx
index 01e7023a..c9934604 100644
--- a/web/src/components/settings/PersonalSetting.jsx
+++ b/web/src/components/settings/PersonalSetting.jsx
@@ -81,6 +81,9 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
barkUrl: '',
+ gotifyUrl: '',
+ gotifyToken: '',
+ gotifyPriority: 5,
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
@@ -149,6 +152,12 @@ const PersonalSetting = () => {
webhookSecret: settings.webhook_secret || '',
notificationEmail: settings.notification_email || '',
barkUrl: settings.bark_url || '',
+ gotifyUrl: settings.gotify_url || '',
+ gotifyToken: settings.gotify_token || '',
+ gotifyPriority:
+ settings.gotify_priority !== undefined
+ ? settings.gotify_priority
+ : 5,
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
@@ -406,6 +415,12 @@ const PersonalSetting = () => {
webhook_secret: notificationSettings.webhookSecret,
notification_email: notificationSettings.notificationEmail,
bark_url: notificationSettings.barkUrl,
+ gotify_url: notificationSettings.gotifyUrl,
+ gotify_token: notificationSettings.gotifyToken,
+ gotify_priority: (() => {
+ const parsed = parseInt(notificationSettings.gotifyPriority);
+ return isNaN(parsed) ? 5 : parsed;
+ })(),
accept_unset_model_ratio_model:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,
diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx
index aad612d2..0c99e285 100644
--- a/web/src/components/settings/personal/cards/NotificationSettings.jsx
+++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx
@@ -400,6 +400,7 @@ const NotificationSettings = ({