Compare commits

...

17 Commits

Author SHA1 Message Date
CaIon
aa35d8db69 refactor: update ConvertToOpenAIVideo method to return byte array and improve error handling 2025-10-14 23:03:17 +08:00
CaIon
64ed7dce4d docs: update README for project cloning and Docker Compose instructions 2025-10-14 17:51:33 +08:00
CaIon
67c321c4fb feat: add Umami and Google Analytics integration 2025-10-14 14:19:49 +08:00
CaIon
b3f50e9dd0 fix: remove redundant error message details for channel retrieval failures 2025-10-14 13:53:33 +08:00
Seefs
ea870a7846 Merge pull request #2038 from seefs001/feature/endpoint_type_log
feat: endpoint type log
2025-10-14 13:06:54 +08:00
Seefs
fa21599fc8 Merge pull request #2036 from etnAtker/siliconflow-images-generations
feat: 添加SiliconFlow图像生成接口自动转换支持
2025-10-14 13:05:04 +08:00
Seefs
e6c42bfbda clean 2025-10-14 00:16:08 +08:00
Seefs
7d480d5ff3 feat: endpoint type log 2025-10-14 00:06:52 +08:00
Seefs
86c63ea4a7 feat: endpoint type log 2025-10-13 22:44:54 +08:00
Seefs
2624c48113 feat: endpoint type log 2025-10-13 22:25:39 +08:00
CaIon
384cba92cf fix: remove redundant error handling for empty Gemini API response 2025-10-13 21:58:50 +08:00
Seefs
7222265fee Merge pull request #2035 from Inblac/devpre
fix(convert): 修复 OpenAI 转 Claude 流时 thinking 块的格式问题
2025-10-13 20:58:43 +08:00
etnAtker
fdbc31eb9a fix: 修复PR的潜在问题
1. 解析ImageRequest的Extra时,处理err
2. DoResponse方法添加RelayModeImagesGenerations(fallthrough)
2025-10-13 20:20:08 +08:00
etnAtker
3172c956f7 feat: 添加SiliconFlow图像生成接口自动转换支持
1. 将对SiliconFlow渠道的RelayModeImagesGenerations请求,转发至v1/images/generations端点。
2. SiliconFlow图像生成接口额外参数适配。
2025-10-13 20:06:33 +08:00
yanggh
8b9188c584 fix(convert): 修复 OpenAI 转 Claude 流时 thinking 块的格式问题
将 `ClaudeMediaMessage.Thinking` 的类型从 `string` 修改为 `*string`,以解决 `omitempty` 导致 `"thinking": ""` 字段在 JSON 序列化时被忽略的问题。

同时更新了 `service/convert.go` 和 `relay/channel/claude/relay-claude.go` 中的相关逻辑,以兼容新的指针类型,确保生成的 Claude 事件流符合官方规范。
2025-10-13 19:32:17 +08:00
CaIon
5fc9152499 fix: improve error handling for email sending failures 2025-10-13 19:21:46 +08:00
CaIon
18b945b9c5 feat: add support for Sora channel type and OpenAI video endpoint 2025-10-13 19:21:45 +08:00
31 changed files with 204 additions and 68 deletions

View File

@@ -165,12 +165,18 @@ New API提供了丰富的功能详细特性请参考[特性说明](https://do
#### 使用Docker Compose部署推荐
```shell
# 下载项目
git clone https://github.com/Calcium-Ion/new-api.git
# 下载项目源码
git clone https://github.com/QuantumNous/new-api.git
# 进入项目目录
cd new-api
# 按需编辑docker-compose.yml
# 启动
docker-compose up -d
# 根据需要编辑 docker-compose.yml 文件
# 使用nano编辑器
nano docker-compose.yml
# 或使用vim编辑器
# vim docker-compose.yml
```
#### 直接使用Docker镜像

View File

@@ -86,5 +86,8 @@ func SendEmail(subject string, receiver string, content string) error {
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
if err != nil {
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
}
return err
}

View File

@@ -26,6 +26,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}
case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
case constant.ChannelTypeSora:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
default:
if IsOpenAIResponseOnlyModel(modelName) {
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}

View File

@@ -10,6 +10,7 @@ const (
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
EndpointTypeEmbeddings EndpointType = "embeddings"
EndpointTypeOpenAIVideo EndpointType = "openai-video"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
//EndpointTypeSuno EndpointType = "suno-proxy"
//EndpointTypeKling EndpointType = "kling"

View File

@@ -229,7 +229,7 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败retry: %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
if channel == nil {
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在retry", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
if newAPIError != nil {
@@ -299,6 +299,9 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
userGroup := c.GetString("group")
channelId := c.GetInt("channel_id")
other := make(map[string]interface{})
if c.Request != nil && c.Request.URL != nil {
other["request_path"] = c.Request.URL.Path
}
other["error_type"] = err.GetErrorType()
other["error_code"] = err.GetErrorCode()
other["status_code"] = err.StatusCode

View File

@@ -88,10 +88,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
}
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
taskResult := &relaycommon.TaskInfo{}
// try parse as New API response format
var responseItems dto.TaskResponse[model.Task]
if err = json.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
t := responseItems.Data
taskResult.TaskID = t.TaskID
taskResult.Status = string(t.Status)
@@ -105,9 +108,12 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.Data = redactVideoResponseBody(responseBody)
}
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
now := time.Now().Unix()
if taskResult.Status == "" {
return fmt.Errorf("task %s status is empty", taskId)
//return fmt.Errorf("task %s status is empty", taskId)
taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
}
task.Status = model.TaskStatus(taskResult.Status)
switch taskResult.Status {

View File

@@ -30,11 +30,14 @@ services:
# - 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 # 是否启用错误日志记录
- 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!!!!!!!
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
# - 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
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID)
# - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Umami 脚本 URL默认为官方地址 (Umami Script URL, defaults to official URL)
depends_on:
- redis

View File

@@ -24,7 +24,7 @@ type ClaudeMediaMessage struct {
StopReason *string `json:"stop_reason,omitempty"`
PartialJson *string `json:"partial_json,omitempty"`
Role string `json:"role,omitempty"`
Thinking string `json:"thinking,omitempty"`
Thinking *string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`

View File

@@ -27,7 +27,7 @@ type OpenAIVideo struct {
Size string `json:"size,omitempty"`
RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"`
Error *OpenAIVideoError `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Metadata map[string]any `json:"meta_data,omitempty"`
}
func (m *OpenAIVideo) SetProgressStr(progress string) {

53
main.go
View File

@@ -150,6 +150,26 @@ func main() {
})
server.Use(sessions.Sessions("session", store))
InjectUmamiAnalytics()
InjectGoogleAnalytics()
// 设置路由
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
port = strconv.Itoa(*common.Port)
}
// Log startup success message
common.LogStartupSuccess(startTime, port)
err = server.Run(":" + port)
if err != nil {
common.FatalLog("failed to start HTTP server: " + err.Error())
}
}
func InjectUmamiAnalytics() {
analyticsInjectBuilder := &strings.Builder{}
if os.Getenv("UMAMI_WEBSITE_ID") != "" {
umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID")
@@ -164,21 +184,28 @@ func main() {
analyticsInjectBuilder.WriteString("\"></script>")
}
analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<analytics></analytics>\n"), []byte(analyticsInject))
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--umami-->\n"), []byte(analyticsInject))
}
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
port = strconv.Itoa(*common.Port)
}
// Log startup success message
common.LogStartupSuccess(startTime, port)
err = server.Run(":" + port)
if err != nil {
common.FatalLog("failed to start HTTP server: " + err.Error())
func InjectGoogleAnalytics() {
analyticsInjectBuilder := &strings.Builder{}
if os.Getenv("GOOGLE_ANALYTICS_ID") != "" {
gaID := os.Getenv("GOOGLE_ANALYTICS_ID")
// Google Analytics 4 (gtag.js)
analyticsInjectBuilder.WriteString("<script async src=\"https://www.googletagmanager.com/gtag/js?id=")
analyticsInjectBuilder.WriteString(gaID)
analyticsInjectBuilder.WriteString("\"></script>")
analyticsInjectBuilder.WriteString("<script>")
analyticsInjectBuilder.WriteString("window.dataLayer = window.dataLayer || [];")
analyticsInjectBuilder.WriteString("function gtag(){dataLayer.push(arguments);}")
analyticsInjectBuilder.WriteString("gtag('js', new Date());")
analyticsInjectBuilder.WriteString("gtag('config', '")
analyticsInjectBuilder.WriteString(gaID)
analyticsInjectBuilder.WriteString("');")
analyticsInjectBuilder.WriteString("</script>")
}
analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--Google Analytics-->\n"), []byte(analyticsInject))
}
func InitResources() error {

View File

@@ -102,7 +102,7 @@ func Distribute() func(c *gin.Context) {
if userGroup == "auto" {
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
}
message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(数据库一致性已被破坏,distributor: %s", showGroup, modelRequest.Model, err.Error())
message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败distributor: %s", showGroup, modelRequest.Model, err.Error())
// 如果错误,但是渠道不为空,说明是数据库一致性问题
//if channel != nil {
// common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))

View File

@@ -53,5 +53,5 @@ type TaskAdaptor interface {
}
type OpenAIVideoConverter interface {
ConvertToOpenAIVideo(originTask *model.Task) (*dto.OpenAIVideo, error)
ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error)
}

View File

@@ -477,8 +477,7 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
signatureContent := "\n"
choice.Delta.ReasoningContent = &signatureContent
case "thinking_delta":
thinkingContent := claudeResponse.Delta.Thinking
choice.Delta.ReasoningContent = &thinkingContent
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
}
}
} else if claudeResponse.Type == "message_delta" {
@@ -513,7 +512,9 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto
var responseThinking string
if len(claudeResponse.Content) > 0 {
responseText = claudeResponse.Content[0].GetText()
responseThinking = claudeResponse.Content[0].Thinking
if claudeResponse.Content[0].Thinking != nil {
responseThinking = *claudeResponse.Content[0].Thinking
}
}
tools := make([]dto.ToolCallResponse, 0)
thinkingContent := ""
@@ -545,7 +546,9 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto
})
case "thinking":
// 加密的不管, 只输出明文的推理过程
thinkingContent = message.Thinking
if message.Thinking != nil {
thinkingContent = *message.Thinking
}
case "text":
responseText = message.GetText()
}
@@ -598,8 +601,8 @@ func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeRespons
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
}
if claudeResponse.Delta.Thinking != "" {
claudeInfo.ResponseText.WriteString(claudeResponse.Delta.Thinking)
if claudeResponse.Delta.Thinking != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
}
} else if claudeResponse.Type == "message_delta" {
// 最终的usage获取

View File

@@ -1061,11 +1061,11 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
}
if len(geminiResponse.Candidates) == 0 {
//return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
return nil, types.NewOpenAIError(errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), types.ErrorCodePromptBlocked, http.StatusBadRequest)
} else {
return nil, types.NewOpenAIError(errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
}
//if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
// return nil, types.NewOpenAIError(errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), types.ErrorCodePromptBlocked, http.StatusBadRequest)
//} else {
// return nil, types.NewOpenAIError(errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
//}
}
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
fullTextResponse.Model = info.UpstreamModelName

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/openai"
@@ -35,8 +36,27 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
adaptor := openai.Adaptor{}
return adaptor.ConvertImageRequest(c, info, request)
// 解析extra到SFImageRequest里以填入SiliconFlow特殊字段。若失败重建一个空的。
sfRequest := &SFImageRequest{}
extra, err := common.Marshal(request.Extra)
if err == nil {
err = common.Unmarshal(extra, sfRequest)
if err != nil {
sfRequest = &SFImageRequest{}
}
}
sfRequest.Model = request.Model
sfRequest.Prompt = request.Prompt
// 优先使用image_size/batch_size否则使用OpenAI标准的size/n
if sfRequest.ImageSize == "" {
sfRequest.ImageSize = request.Size
}
if sfRequest.BatchSize == 0 {
sfRequest.BatchSize = request.N
}
return sfRequest, nil
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -51,6 +71,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
} else if info.RelayMode == constant.RelayModeCompletions {
return fmt.Sprintf("%s/v1/completions", info.ChannelBaseUrl), nil
} else if info.RelayMode == constant.RelayModeImagesGenerations {
return fmt.Sprintf("%s/v1/images/generations", info.ChannelBaseUrl), nil
}
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
@@ -102,6 +124,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
fallthrough
case constant.RelayModeChatCompletions:
fallthrough
case constant.RelayModeImagesGenerations:
fallthrough
default:
if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)

View File

@@ -15,3 +15,18 @@ type SFRerankResponse struct {
Results []dto.RerankResponseResult `json:"results"`
Meta SFMeta `json:"meta"`
}
type SFImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
NegativePrompt string `json:"negative_prompt,omitempty"`
ImageSize string `json:"image_size,omitempty"`
BatchSize uint `json:"batch_size,omitempty"`
Seed uint64 `json:"seed,omitempty"`
NumInferenceSteps uint `json:"num_inference_steps,omitempty"`
GuidanceScale float64 `json:"guidance_scale,omitempty"`
Cfg float64 `json:"cfg,omitempty"`
Image string `json:"image,omitempty"`
Image2 string `json:"image2,omitempty"`
Image3 string `json:"image3,omitempty"`
}

View File

@@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
@@ -446,7 +447,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
return &taskResult, nil
}
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) (*dto.OpenAIVideo, error) {
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
var jimengResp responseTask
if err := json.Unmarshal(originTask.Data, &jimengResp); err != nil {
return nil, errors.Wrap(err, "unmarshal jimeng task data failed")
@@ -467,7 +468,8 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) (*dto.OpenAIV
}
}
return openAIVideo, nil
jsonData, _ := common.Marshal(openAIVideo)
return jsonData, nil
}
func isNewAPIRelay(apiKey string) bool {

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/samber/lo"
@@ -367,7 +368,7 @@ func isNewAPIRelay(apiKey string) bool {
return strings.HasPrefix(apiKey, "sk-")
}
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) (*dto.OpenAIVideo, error) {
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
var klingResp responsePayload
if err := json.Unmarshal(originTask.Data, &klingResp); err != nil {
return nil, errors.Wrap(err, "unmarshal kling task data failed")
@@ -396,6 +397,6 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) (*dto.OpenAIV
Code: fmt.Sprintf("%d", klingResp.Code),
}
}
return openAIVideo, nil
jsonData, _ := common.Marshal(openAIVideo)
return jsonData, nil
}

View File

@@ -2,7 +2,6 @@ package sora
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -107,7 +106,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relayco
// Parse Sora response
var dResp responseTask
if err := json.Unmarshal(responseBody, &dResp); err != nil {
if err := common.Unmarshal(responseBody, &dResp); err != nil {
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
return
}
@@ -154,7 +153,7 @@ func (a *TaskAdaptor) GetChannelName() string {
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
resTask := responseTask{}
if err := json.Unmarshal(respBody, &resTask); err != nil {
if err := common.Unmarshal(respBody, &resTask); err != nil {
return nil, errors.Wrap(err, "unmarshal task result failed")
}
@@ -186,11 +185,6 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
return &taskResult, nil
}
func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) (*dto.OpenAIVideo, error) {
openAIVideo := &dto.OpenAIVideo{}
err := json.Unmarshal(task.Data, openAIVideo)
if err != nil {
return nil, errors.Wrap(err, "unmarshal to OpenAIVideo failed")
}
return openAIVideo, nil
func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
return task.Data, nil
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
"github.com/QuantumNous/new-api/constant"
@@ -263,7 +264,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
return taskInfo, nil
}
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) (*dto.OpenAIVideo, error) {
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
var viduResp taskResultResponse
if err := json.Unmarshal(originTask.Data, &viduResp); err != nil {
return nil, errors.Wrap(err, "unmarshal vidu task data failed")
@@ -287,5 +288,6 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) (*dto.OpenAIV
}
}
return openAIVideo, nil
jsonData, _ := common.Marshal(openAIVideo)
return jsonData, nil
}

View File

@@ -512,6 +512,13 @@ type TaskInfo struct {
TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
}
func FailTaskInfo(reason string) *TaskInfo {
return &TaskInfo{
Status: "FAILURE",
Reason: reason,
}
}
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
// service_tier: 服务层级字段可能导致额外计费OpenAI、Claude、Responses API 支持)
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)

View File

@@ -218,7 +218,7 @@ func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyR
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, constant.MjActionSwapFace)
other := service.GenerateMjOtherInfo(priceData)
other := service.GenerateMjOtherInfo(info, priceData)
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: info.ChannelId,
ModelName: modelName,
@@ -518,7 +518,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
}
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %sID %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, midjRequest.Action, midjResponse.Result)
other := service.GenerateMjOtherInfo(priceData)
other := service.GenerateMjOtherInfo(relayInfo, priceData)
model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
ModelName: modelName,

View File

@@ -165,6 +165,9 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.
}
}
other := make(map[string]interface{})
if c != nil && c.Request != nil && c.Request.URL != nil {
other["request_path"] = c.Request.URL.Path
}
other["model_price"] = modelPrice
other["group_ratio"] = groupRatio
if hasUserGroupRatio {
@@ -394,12 +397,12 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d
return
}
if converter, ok := adaptor.(channel.OpenAIVideoConverter); ok {
openAIVideo, err := converter.ConvertToOpenAIVideo(originTask)
openAIVideoData, err := converter.ConvertToOpenAIVideo(originTask)
if err != nil {
taskResp = service.TaskErrorWrapper(err, "convert_to_openai_video_failed", http.StatusInternalServerError)
return
}
respBody, _ = json.Marshal(openAIVideo)
respBody = openAIVideoData
return
}
taskResp = service.TaskErrorWrapperLocal(errors.New(fmt.Sprintf("not_implemented:%s", originTask.Platform)), "not_implemented", http.StatusNotImplemented)

View File

@@ -352,7 +352,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: "",
Thinking: common.GetPointer[string](""),
},
})
}
@@ -360,7 +360,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
// text delta
claudeResponse.Delta = &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: reasoning,
Thinking: &reasoning,
}
} else {
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {

View File

@@ -1,6 +1,8 @@
package service
import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
@@ -10,6 +12,25 @@ import (
"github.com/gin-gonic/gin"
)
func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if other == nil {
return
}
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil {
if path := ctx.Request.URL.Path; path != "" {
other["request_path"] = path
return
}
}
if relayInfo != nil && relayInfo.RequestURLPath != "" {
path := relayInfo.RequestURLPath
if idx := strings.Index(path, "?"); idx != -1 {
path = path[:idx]
}
other["request_path"] = path
}
}
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} {
other := make(map[string]interface{})
@@ -42,6 +63,7 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
adminInfo["multi_key_index"] = common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex)
}
other["admin_info"] = adminInfo
appendRequestPath(ctx, relayInfo, other)
return other
}
@@ -78,12 +100,13 @@ func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
return info
}
func GenerateMjOtherInfo(priceData types.PerCallPriceData) map[string]interface{} {
func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.PerCallPriceData) map[string]interface{} {
other := make(map[string]interface{})
other["model_price"] = priceData.ModelPrice
other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio
if priceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio
}
appendRequestPath(nil, relayInfo, other)
return other
}

View File

@@ -10,7 +10,8 @@
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
/>
<title>New API</title>
<analytics></analytics>
<!--umami-->
<!--Google Analytics-->
</head>
<body>

View File

@@ -468,6 +468,12 @@ export const useLogsData = () => {
});
}
}
if (other?.request_path) {
expandDataLocal.push({
key: t('请求路径'),
value: other.request_path,
});
}
expandDatesLocal[logs[i].key] = expandDataLocal;
}

View File

@@ -1675,6 +1675,7 @@
"请求失败": "Request failed",
"请求头覆盖": "Request header override",
"请求并计费模型": "Request and charge model",
"请求路径": "Request path",
"请求时长: ${time}s": "Request time: ${time}s",
"请求次数": "Number of Requests",
"请求结束后多退少补": "Adjust after request completion",

View File

@@ -1684,6 +1684,7 @@
"请求失败": "Échec de la demande",
"请求头覆盖": "Remplacement des en-têtes de demande",
"请求并计费模型": "Modèle de demande et de facturation",
"请求路径": "Chemin de requête",
"请求时长: ${time}s": "Durée de la requête : ${time}s",
"请求次数": "Nombre de demandes",
"请求结束后多退少补": "Ajuster après la fin de la demande",
@@ -2081,4 +2082,4 @@
"默认测试模型": "Modèle de test par défaut",
"默认补全倍率": "Taux de complétion par défaut"
}
}
}

View File

@@ -1693,6 +1693,7 @@
"请求失败": "Запрос не удался",
"请求头覆盖": "Переопределение заголовков запроса",
"请求并计费模型": "Запрос и выставление счёта модели",
"请求路径": "Путь запроса",
"请求时长: ${time}s": "Время запроса: ${time}s",
"请求次数": "Количество запросов",
"请求结束后多退少补": "После вывода запроса возврат излишков и доплата недостатка",

View File

@@ -1666,6 +1666,7 @@
"请求失败": "请求失败",
"请求头覆盖": "请求头覆盖",
"请求并计费模型": "请求并计费模型",
"请求路径": "请求路径",
"请求时长: ${time}s": "请求时长: ${time}s",
"请求次数": "请求次数",
"请求结束后多退少补": "请求结束后多退少补",