Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -592,7 +592,14 @@ func UpdateSelf(c *gin.Context) {
|
|||||||
user.Password = "" // rollback to what it should be
|
user.Password = "" // rollback to what it should be
|
||||||
cleanUser.Password = ""
|
cleanUser.Password = ""
|
||||||
}
|
}
|
||||||
updatePassword := user.Password != ""
|
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := cleanUser.Update(updatePassword); err != nil {
|
if err := cleanUser.Update(updatePassword); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -608,6 +615,23 @@ func UpdateSelf(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkUpdatePassword(originalPassword string, newPassword string, userId int) (updatePassword bool, err error) {
|
||||||
|
var currentUser *model.User
|
||||||
|
currentUser, err = model.GetUserById(userId, true)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) {
|
||||||
|
err = fmt.Errorf("原密码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newPassword == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updatePassword = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func DeleteUser(c *gin.Context) {
|
func DeleteUser(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -195,28 +195,28 @@ type OutputTokenDetails struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OpenAIResponsesResponse struct {
|
type OpenAIResponsesResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
CreatedAt int `json:"created_at"`
|
CreatedAt int `json:"created_at"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error *OpenAIError `json:"error,omitempty"`
|
Error *OpenAIError `json:"error,omitempty"`
|
||||||
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
|
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
|
||||||
Instructions string `json:"instructions"`
|
Instructions string `json:"instructions"`
|
||||||
MaxOutputTokens int `json:"max_output_tokens"`
|
MaxOutputTokens int `json:"max_output_tokens"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Output []ResponsesOutput `json:"output"`
|
Output []ResponsesOutput `json:"output"`
|
||||||
ParallelToolCalls bool `json:"parallel_tool_calls"`
|
ParallelToolCalls bool `json:"parallel_tool_calls"`
|
||||||
PreviousResponseID string `json:"previous_response_id"`
|
PreviousResponseID string `json:"previous_response_id"`
|
||||||
Reasoning *Reasoning `json:"reasoning"`
|
Reasoning *Reasoning `json:"reasoning"`
|
||||||
Store bool `json:"store"`
|
Store bool `json:"store"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
ToolChoice string `json:"tool_choice"`
|
ToolChoice string `json:"tool_choice"`
|
||||||
Tools []interface{} `json:"tools"`
|
Tools []ResponsesToolsCall `json:"tools"`
|
||||||
TopP float64 `json:"top_p"`
|
TopP float64 `json:"top_p"`
|
||||||
Truncation string `json:"truncation"`
|
Truncation string `json:"truncation"`
|
||||||
Usage *Usage `json:"usage"`
|
Usage *Usage `json:"usage"`
|
||||||
User json.RawMessage `json:"user"`
|
User json.RawMessage `json:"user"`
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
Metadata json.RawMessage `json:"metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type IncompleteDetails struct {
|
type IncompleteDetails struct {
|
||||||
@@ -238,8 +238,12 @@ type ResponsesOutputContent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BuildInTools_WebSearch = "web_search_preview"
|
BuildInToolWebSearchPreview = "web_search_preview"
|
||||||
BuildInTools_FileSearch = "file_search"
|
BuildInToolFileSearch = "file_search"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BuildInCallWebSearchCall = "web_search_call"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -250,6 +254,7 @@ const (
|
|||||||
// ResponsesStreamResponse 用于处理 /v1/responses 流式响应
|
// ResponsesStreamResponse 用于处理 /v1/responses 流式响应
|
||||||
type ResponsesStreamResponse struct {
|
type ResponsesStreamResponse struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Response *OpenAIResponsesResponse `json:"response"`
|
Response *OpenAIResponsesResponse `json:"response,omitempty"`
|
||||||
Delta string `json:"delta,omitempty"`
|
Delta string `json:"delta,omitempty"`
|
||||||
|
Item *ResponsesOutput `json:"item,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,25 +93,27 @@ func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) g
|
|||||||
}
|
}
|
||||||
|
|
||||||
//2.检查总请求数限制并记录总请求(当totalMaxCount为0时会自动跳过,使用令牌桶限流器
|
//2.检查总请求数限制并记录总请求(当totalMaxCount为0时会自动跳过,使用令牌桶限流器
|
||||||
totalKey := fmt.Sprintf("rateLimit:%s", userId)
|
if totalMaxCount > 0 {
|
||||||
// 初始化
|
totalKey := fmt.Sprintf("rateLimit:%s", userId)
|
||||||
tb := limiter.New(ctx, rdb)
|
// 初始化
|
||||||
allowed, err = tb.Allow(
|
tb := limiter.New(ctx, rdb)
|
||||||
ctx,
|
allowed, err = tb.Allow(
|
||||||
totalKey,
|
ctx,
|
||||||
limiter.WithCapacity(int64(totalMaxCount)*duration),
|
totalKey,
|
||||||
limiter.WithRate(int64(totalMaxCount)),
|
limiter.WithCapacity(int64(totalMaxCount)*duration),
|
||||||
limiter.WithRequested(duration),
|
limiter.WithRate(int64(totalMaxCount)),
|
||||||
)
|
limiter.WithRequested(duration),
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("检查总请求数限制失败:", err.Error())
|
fmt.Println("检查总请求数限制失败:", err.Error())
|
||||||
abortWithOpenAiMessage(c, http.StatusInternalServerError, "rate_limit_check_failed")
|
abortWithOpenAiMessage(c, http.StatusInternalServerError, "rate_limit_check_failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !allowed {
|
if !allowed {
|
||||||
abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到总请求数限制:%d分钟内最多请求%d次,包括失败次数,请检查您的请求是否正确", setting.ModelRequestRateLimitDurationMinutes, totalMaxCount))
|
abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到总请求数限制:%d分钟内最多请求%d次,包括失败次数,请检查您的请求是否正确", setting.ModelRequestRateLimitDurationMinutes, totalMaxCount))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 处理请求
|
// 4. 处理请求
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type User struct {
|
|||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Username string `json:"username" gorm:"unique;index" validate:"max=12"`
|
Username string `json:"username" gorm:"unique;index" validate:"max=12"`
|
||||||
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
|
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
|
||||||
|
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
|
||||||
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
|
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
|
||||||
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
|
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
|
||||||
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
|
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
|||||||
if info.IsStream {
|
if info.IsStream {
|
||||||
err, usage = OaiResponsesStreamHandler(c, resp, info)
|
err, usage = OaiResponsesStreamHandler(c, resp, info)
|
||||||
} else {
|
} else {
|
||||||
err, usage = OpenaiResponsesHandler(c, resp, info)
|
err, usage = OaiResponsesHandler(c, resp, info)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if info.IsStream {
|
if info.IsStream {
|
||||||
|
|||||||
@@ -187,3 +187,10 @@ func handleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamResponse, data string) {
|
||||||
|
if data == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.ResponseChunkData(c, streamResponse, data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -644,102 +644,3 @@ func OpenaiHandlerWithUsage(c *gin.Context, resp *http.Response, info *relaycomm
|
|||||||
}
|
}
|
||||||
return nil, &usageResp.Usage
|
return nil, &usageResp.Usage
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenaiResponsesHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
|
|
||||||
// read response body
|
|
||||||
var responsesResponse dto.OpenAIResponsesResponse
|
|
||||||
responseBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
|
|
||||||
}
|
|
||||||
err = resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
|
|
||||||
}
|
|
||||||
err = common.DecodeJson(responseBody, &responsesResponse)
|
|
||||||
if err != nil {
|
|
||||||
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
|
|
||||||
}
|
|
||||||
if responsesResponse.Error != nil {
|
|
||||||
return &dto.OpenAIErrorWithStatusCode{
|
|
||||||
Error: dto.OpenAIError{
|
|
||||||
Message: responsesResponse.Error.Message,
|
|
||||||
Type: "openai_error",
|
|
||||||
Code: responsesResponse.Error.Code,
|
|
||||||
},
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset response body
|
|
||||||
resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
|
|
||||||
// We shouldn't set the header before we parse the response body, because the parse part may fail.
|
|
||||||
// And then we will have to send an error response, but in this case, the header has already been set.
|
|
||||||
// So the httpClient will be confused by the response.
|
|
||||||
// For example, Postman will report error, and we cannot check the response at all.
|
|
||||||
for k, v := range resp.Header {
|
|
||||||
c.Writer.Header().Set(k, v[0])
|
|
||||||
}
|
|
||||||
c.Writer.WriteHeader(resp.StatusCode)
|
|
||||||
// copy response body
|
|
||||||
_, err = io.Copy(c.Writer, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
common.SysError("error copying response body: " + err.Error())
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
// compute usage
|
|
||||||
usage := dto.Usage{}
|
|
||||||
usage.PromptTokens = responsesResponse.Usage.InputTokens
|
|
||||||
usage.CompletionTokens = responsesResponse.Usage.OutputTokens
|
|
||||||
usage.TotalTokens = responsesResponse.Usage.TotalTokens
|
|
||||||
return nil, &usage
|
|
||||||
}
|
|
||||||
|
|
||||||
func OaiResponsesStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
|
|
||||||
if resp == nil || resp.Body == nil {
|
|
||||||
common.LogError(c, "invalid response or response body")
|
|
||||||
return service.OpenAIErrorWrapper(fmt.Errorf("invalid response"), "invalid_response", http.StatusInternalServerError), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var usage = &dto.Usage{}
|
|
||||||
var responseTextBuilder strings.Builder
|
|
||||||
|
|
||||||
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
|
||||||
|
|
||||||
// 检查当前数据是否包含 completed 状态和 usage 信息
|
|
||||||
var streamResponse dto.ResponsesStreamResponse
|
|
||||||
if err := common.DecodeJsonStr(data, &streamResponse); err == nil {
|
|
||||||
sendResponsesStreamData(c, streamResponse, data)
|
|
||||||
switch streamResponse.Type {
|
|
||||||
case "response.completed":
|
|
||||||
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
|
|
||||||
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
|
|
||||||
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
|
|
||||||
case "response.output_text.delta":
|
|
||||||
// 处理输出文本
|
|
||||||
responseTextBuilder.WriteString(streamResponse.Delta)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if usage.CompletionTokens == 0 {
|
|
||||||
// 计算输出文本的 token 数量
|
|
||||||
tempStr := responseTextBuilder.String()
|
|
||||||
if len(tempStr) > 0 {
|
|
||||||
// 非正常结束,使用输出文本的 token 数量
|
|
||||||
completionTokens, _ := service.CountTextToken(tempStr, info.UpstreamModelName)
|
|
||||||
usage.CompletionTokens = completionTokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, usage
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamResponse, data string) {
|
|
||||||
if data == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.ResponseChunkData(c, streamResponse, data)
|
|
||||||
}
|
|
||||||
|
|||||||
119
relay/channel/openai/relay_responses.go
Normal file
119
relay/channel/openai/relay_responses.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"one-api/common"
|
||||||
|
"one-api/dto"
|
||||||
|
relaycommon "one-api/relay/common"
|
||||||
|
"one-api/relay/helper"
|
||||||
|
"one-api/service"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OaiResponsesHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
|
||||||
|
// read response body
|
||||||
|
var responsesResponse dto.OpenAIResponsesResponse
|
||||||
|
responseBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
|
||||||
|
}
|
||||||
|
err = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
|
||||||
|
}
|
||||||
|
err = common.DecodeJson(responseBody, &responsesResponse)
|
||||||
|
if err != nil {
|
||||||
|
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
|
||||||
|
}
|
||||||
|
if responsesResponse.Error != nil {
|
||||||
|
return &dto.OpenAIErrorWithStatusCode{
|
||||||
|
Error: dto.OpenAIError{
|
||||||
|
Message: responsesResponse.Error.Message,
|
||||||
|
Type: "openai_error",
|
||||||
|
Code: responsesResponse.Error.Code,
|
||||||
|
},
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset response body
|
||||||
|
resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
|
||||||
|
// We shouldn't set the header before we parse the response body, because the parse part may fail.
|
||||||
|
// And then we will have to send an error response, but in this case, the header has already been set.
|
||||||
|
// So the httpClient will be confused by the response.
|
||||||
|
// For example, Postman will report error, and we cannot check the response at all.
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
c.Writer.Header().Set(k, v[0])
|
||||||
|
}
|
||||||
|
c.Writer.WriteHeader(resp.StatusCode)
|
||||||
|
// copy response body
|
||||||
|
_, err = io.Copy(c.Writer, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
common.SysError("error copying response body: " + err.Error())
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
// compute usage
|
||||||
|
usage := dto.Usage{}
|
||||||
|
usage.PromptTokens = responsesResponse.Usage.InputTokens
|
||||||
|
usage.CompletionTokens = responsesResponse.Usage.OutputTokens
|
||||||
|
usage.TotalTokens = responsesResponse.Usage.TotalTokens
|
||||||
|
// 解析 Tools 用量
|
||||||
|
for _, tool := range responsesResponse.Tools {
|
||||||
|
info.ResponsesUsageInfo.BuiltInTools[tool.Type].CallCount++
|
||||||
|
}
|
||||||
|
return nil, &usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func OaiResponsesStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
|
||||||
|
if resp == nil || resp.Body == nil {
|
||||||
|
common.LogError(c, "invalid response or response body")
|
||||||
|
return service.OpenAIErrorWrapper(fmt.Errorf("invalid response"), "invalid_response", http.StatusInternalServerError), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var usage = &dto.Usage{}
|
||||||
|
var responseTextBuilder strings.Builder
|
||||||
|
|
||||||
|
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||||
|
|
||||||
|
// 检查当前数据是否包含 completed 状态和 usage 信息
|
||||||
|
var streamResponse dto.ResponsesStreamResponse
|
||||||
|
if err := common.DecodeJsonStr(data, &streamResponse); err == nil {
|
||||||
|
sendResponsesStreamData(c, streamResponse, data)
|
||||||
|
switch streamResponse.Type {
|
||||||
|
case "response.completed":
|
||||||
|
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
|
||||||
|
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
|
||||||
|
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
|
||||||
|
case "response.output_text.delta":
|
||||||
|
// 处理输出文本
|
||||||
|
responseTextBuilder.WriteString(streamResponse.Delta)
|
||||||
|
case dto.ResponsesOutputTypeItemDone:
|
||||||
|
// 函数调用处理
|
||||||
|
if streamResponse.Item != nil {
|
||||||
|
switch streamResponse.Item.Type {
|
||||||
|
case dto.BuildInCallWebSearchCall:
|
||||||
|
info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if usage.CompletionTokens == 0 {
|
||||||
|
// 计算输出文本的 token 数量
|
||||||
|
tempStr := responseTextBuilder.String()
|
||||||
|
if len(tempStr) > 0 {
|
||||||
|
// 非正常结束,使用输出文本的 token 数量
|
||||||
|
completionTokens, _ := service.CountTextToken(tempStr, info.UpstreamModelName)
|
||||||
|
usage.CompletionTokens = completionTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, usage
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"one-api/relay/channel/claude"
|
"one-api/relay/channel/claude"
|
||||||
"one-api/relay/channel/gemini"
|
"one-api/relay/channel/gemini"
|
||||||
"one-api/relay/channel/openai"
|
"one-api/relay/channel/openai"
|
||||||
"one-api/setting/model_setting"
|
|
||||||
relaycommon "one-api/relay/common"
|
relaycommon "one-api/relay/common"
|
||||||
|
"one-api/setting/model_setting"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type ClaudeConvertInfo struct {
|
|||||||
const (
|
const (
|
||||||
RelayFormatOpenAI = "openai"
|
RelayFormatOpenAI = "openai"
|
||||||
RelayFormatClaude = "claude"
|
RelayFormatClaude = "claude"
|
||||||
|
RelayFormatGemini = "gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RerankerInfo struct {
|
type RerankerInfo struct {
|
||||||
@@ -43,6 +44,16 @@ type RerankerInfo struct {
|
|||||||
ReturnDocuments bool
|
ReturnDocuments bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BuildInToolInfo struct {
|
||||||
|
ToolName string
|
||||||
|
CallCount int
|
||||||
|
SearchContextSize string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResponsesUsageInfo struct {
|
||||||
|
BuiltInTools map[string]*BuildInToolInfo
|
||||||
|
}
|
||||||
|
|
||||||
type RelayInfo struct {
|
type RelayInfo struct {
|
||||||
ChannelType int
|
ChannelType int
|
||||||
ChannelId int
|
ChannelId int
|
||||||
@@ -90,6 +101,7 @@ type RelayInfo struct {
|
|||||||
ThinkingContentInfo
|
ThinkingContentInfo
|
||||||
*ClaudeConvertInfo
|
*ClaudeConvertInfo
|
||||||
*RerankerInfo
|
*RerankerInfo
|
||||||
|
*ResponsesUsageInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义支持流式选项的通道类型
|
// 定义支持流式选项的通道类型
|
||||||
@@ -135,6 +147,31 @@ func GenRelayInfoRerank(c *gin.Context, req *dto.RerankRequest) *RelayInfo {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GenRelayInfoResponses(c *gin.Context, req *dto.OpenAIResponsesRequest) *RelayInfo {
|
||||||
|
info := GenRelayInfo(c)
|
||||||
|
info.RelayMode = relayconstant.RelayModeResponses
|
||||||
|
info.ResponsesUsageInfo = &ResponsesUsageInfo{
|
||||||
|
BuiltInTools: make(map[string]*BuildInToolInfo),
|
||||||
|
}
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
for _, tool := range req.Tools {
|
||||||
|
info.ResponsesUsageInfo.BuiltInTools[tool.Type] = &BuildInToolInfo{
|
||||||
|
ToolName: tool.Type,
|
||||||
|
CallCount: 0,
|
||||||
|
}
|
||||||
|
switch tool.Type {
|
||||||
|
case dto.BuildInToolWebSearchPreview:
|
||||||
|
if tool.SearchContextSize == "" {
|
||||||
|
tool.SearchContextSize = "medium"
|
||||||
|
}
|
||||||
|
info.ResponsesUsageInfo.BuiltInTools[tool.Type].SearchContextSize = tool.SearchContextSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.IsStream = req.Stream
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
func GenRelayInfo(c *gin.Context) *RelayInfo {
|
func GenRelayInfo(c *gin.Context) *RelayInfo {
|
||||||
channelType := c.GetInt("channel_type")
|
channelType := c.GetInt("channel_type")
|
||||||
channelId := c.GetInt("channel_id")
|
channelId := c.GetInt("channel_id")
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package helper
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"one-api/relay/common"
|
"one-api/relay/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ModelMappedHelper(c *gin.Context, info *common.RelayInfo) error {
|
func ModelMappedHelper(c *gin.Context, info *common.RelayInfo) error {
|
||||||
@@ -16,9 +18,36 @@ func ModelMappedHelper(c *gin.Context, info *common.RelayInfo) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unmarshal_model_mapping_failed")
|
return fmt.Errorf("unmarshal_model_mapping_failed")
|
||||||
}
|
}
|
||||||
if modelMap[info.OriginModelName] != "" {
|
|
||||||
info.UpstreamModelName = modelMap[info.OriginModelName]
|
// 支持链式模型重定向,最终使用链尾的模型
|
||||||
info.IsModelMapped = true
|
currentModel := info.OriginModelName
|
||||||
|
visitedModels := map[string]bool{
|
||||||
|
currentModel: true,
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if mappedModel, exists := modelMap[currentModel]; exists && mappedModel != "" {
|
||||||
|
// 模型重定向循环检测,避免无限循环
|
||||||
|
if visitedModels[mappedModel] {
|
||||||
|
if mappedModel == currentModel {
|
||||||
|
if currentModel == info.OriginModelName {
|
||||||
|
info.IsModelMapped = false
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
info.IsModelMapped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("model_mapping_contains_cycle")
|
||||||
|
}
|
||||||
|
visitedModels[mappedModel] = true
|
||||||
|
currentModel = mappedModel
|
||||||
|
info.IsModelMapped = true
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.IsModelMapped {
|
||||||
|
info.UpstreamModelName = currentModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getAndValidateResponsesRequest(c *gin.Context, relayInfo *relaycommon.RelayInfo) (*dto.OpenAIResponsesRequest, error) {
|
func getAndValidateResponsesRequest(c *gin.Context) (*dto.OpenAIResponsesRequest, error) {
|
||||||
request := &dto.OpenAIResponsesRequest{}
|
request := &dto.OpenAIResponsesRequest{}
|
||||||
err := common.UnmarshalBodyReusable(c, request)
|
err := common.UnmarshalBodyReusable(c, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,13 +31,11 @@ func getAndValidateResponsesRequest(c *gin.Context, relayInfo *relaycommon.Relay
|
|||||||
if len(request.Input) == 0 {
|
if len(request.Input) == 0 {
|
||||||
return nil, errors.New("input is required")
|
return nil, errors.New("input is required")
|
||||||
}
|
}
|
||||||
relayInfo.IsStream = request.Stream
|
|
||||||
return request, nil
|
return request, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkInputSensitive(textRequest *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) ([]string, error) {
|
func checkInputSensitive(textRequest *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) ([]string, error) {
|
||||||
|
|
||||||
sensitiveWords, err := service.CheckSensitiveInput(textRequest.Input)
|
sensitiveWords, err := service.CheckSensitiveInput(textRequest.Input)
|
||||||
return sensitiveWords, err
|
return sensitiveWords, err
|
||||||
}
|
}
|
||||||
@@ -49,12 +47,14 @@ func getInputTokens(req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResponsesHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
|
func ResponsesHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
|
||||||
relayInfo := relaycommon.GenRelayInfo(c)
|
req, err := getAndValidateResponsesRequest(c)
|
||||||
req, err := getAndValidateResponsesRequest(c, relayInfo)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.LogError(c, fmt.Sprintf("getAndValidateResponsesRequest error: %s", err.Error()))
|
common.LogError(c, fmt.Sprintf("getAndValidateResponsesRequest error: %s", err.Error()))
|
||||||
return service.OpenAIErrorWrapperLocal(err, "invalid_responses_request", http.StatusBadRequest)
|
return service.OpenAIErrorWrapperLocal(err, "invalid_responses_request", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
relayInfo := relaycommon.GenRelayInfoResponses(c, req)
|
||||||
|
|
||||||
if setting.ShouldCheckPromptSensitive() {
|
if setting.ShouldCheckPromptSensitive() {
|
||||||
sensitiveWords, err := checkInputSensitive(req, relayInfo)
|
sensitiveWords, err := checkInputSensitive(req, relayInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"one-api/service"
|
"one-api/service"
|
||||||
"one-api/setting"
|
"one-api/setting"
|
||||||
"one-api/setting/model_setting"
|
"one-api/setting/model_setting"
|
||||||
|
"one-api/setting/operation_setting"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -358,6 +359,34 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
|
|
||||||
ratio := dModelRatio.Mul(dGroupRatio)
|
ratio := dModelRatio.Mul(dGroupRatio)
|
||||||
|
|
||||||
|
// openai web search 工具计费
|
||||||
|
var dWebSearchQuota decimal.Decimal
|
||||||
|
var webSearchPrice float64
|
||||||
|
if relayInfo.ResponsesUsageInfo != nil {
|
||||||
|
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool.CallCount > 0 {
|
||||||
|
// 计算 web search 调用的配额 (配额 = 价格 * 调用次数 / 1000)
|
||||||
|
webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, webSearchTool.SearchContextSize)
|
||||||
|
dWebSearchQuota = decimal.NewFromFloat(webSearchPrice).
|
||||||
|
Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))).
|
||||||
|
Div(decimal.NewFromInt(1000))
|
||||||
|
extraContent += fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 $%s",
|
||||||
|
webSearchTool.CallCount, webSearchTool.SearchContextSize, dWebSearchQuota.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// file search tool 计费
|
||||||
|
var dFileSearchQuota decimal.Decimal
|
||||||
|
var fileSearchPrice float64
|
||||||
|
if relayInfo.ResponsesUsageInfo != nil {
|
||||||
|
if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists && fileSearchTool.CallCount > 0 {
|
||||||
|
fileSearchPrice = operation_setting.GetFileSearchPricePerThousand()
|
||||||
|
dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
|
||||||
|
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
|
||||||
|
Div(decimal.NewFromInt(1000))
|
||||||
|
extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 $%s",
|
||||||
|
fileSearchTool.CallCount, dFileSearchQuota.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var quotaCalculateDecimal decimal.Decimal
|
var quotaCalculateDecimal decimal.Decimal
|
||||||
if !priceData.UsePrice {
|
if !priceData.UsePrice {
|
||||||
nonCachedTokens := dPromptTokens.Sub(dCacheTokens)
|
nonCachedTokens := dPromptTokens.Sub(dCacheTokens)
|
||||||
@@ -380,6 +409,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
} else {
|
} else {
|
||||||
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
|
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
|
||||||
}
|
}
|
||||||
|
// 添加 responses tools call 调用的配额
|
||||||
|
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
|
||||||
|
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
||||||
|
|
||||||
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
||||||
totalTokens := promptTokens + completionTokens
|
totalTokens := promptTokens + completionTokens
|
||||||
@@ -430,6 +462,20 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
other["image_ratio"] = imageRatio
|
other["image_ratio"] = imageRatio
|
||||||
other["image_output"] = imageTokens
|
other["image_output"] = imageTokens
|
||||||
}
|
}
|
||||||
|
if !dWebSearchQuota.IsZero() && relayInfo.ResponsesUsageInfo != nil {
|
||||||
|
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists {
|
||||||
|
other["web_search"] = true
|
||||||
|
other["web_search_call_count"] = webSearchTool.CallCount
|
||||||
|
other["web_search_price"] = webSearchPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dFileSearchQuota.IsZero() && relayInfo.ResponsesUsageInfo != nil {
|
||||||
|
if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists {
|
||||||
|
other["file_search"] = true
|
||||||
|
other["file_search_call_count"] = fileSearchTool.CallCount
|
||||||
|
other["file_search_price"] = fileSearchPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
|
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
|
||||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
||||||
}
|
}
|
||||||
|
|||||||
57
setting/operation_setting/tools.go
Normal file
57
setting/operation_setting/tools.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package operation_setting
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Web search
|
||||||
|
WebSearchHighTierModelPriceLow = 30.00
|
||||||
|
WebSearchHighTierModelPriceMedium = 35.00
|
||||||
|
WebSearchHighTierModelPriceHigh = 50.00
|
||||||
|
WebSearchPriceLow = 25.00
|
||||||
|
WebSearchPriceMedium = 27.50
|
||||||
|
WebSearchPriceHigh = 30.00
|
||||||
|
// File search
|
||||||
|
FileSearchPrice = 2.5
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
|
||||||
|
// 确定模型类型
|
||||||
|
// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费
|
||||||
|
// gpt-4.1, gpt-4o, or gpt-4o-search-preview 更贵,gpt-4.1-mini, gpt-4o-mini, gpt-4o-mini-search-preview 更便宜
|
||||||
|
isHighTierModel := (strings.HasPrefix(modelName, "gpt-4.1") || strings.HasPrefix(modelName, "gpt-4o")) &&
|
||||||
|
!strings.Contains(modelName, "mini")
|
||||||
|
// 确定 search context size 对应的价格
|
||||||
|
var priceWebSearchPerThousandCalls float64
|
||||||
|
switch contextSize {
|
||||||
|
case "low":
|
||||||
|
if isHighTierModel {
|
||||||
|
priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceLow
|
||||||
|
} else {
|
||||||
|
priceWebSearchPerThousandCalls = WebSearchPriceLow
|
||||||
|
}
|
||||||
|
case "medium":
|
||||||
|
if isHighTierModel {
|
||||||
|
priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceMedium
|
||||||
|
} else {
|
||||||
|
priceWebSearchPerThousandCalls = WebSearchPriceMedium
|
||||||
|
}
|
||||||
|
case "high":
|
||||||
|
if isHighTierModel {
|
||||||
|
priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceHigh
|
||||||
|
} else {
|
||||||
|
priceWebSearchPerThousandCalls = WebSearchPriceHigh
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// search context size 默认为 medium
|
||||||
|
if isHighTierModel {
|
||||||
|
priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceMedium
|
||||||
|
} else {
|
||||||
|
priceWebSearchPerThousandCalls = WebSearchPriceMedium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return priceWebSearchPerThousandCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFileSearchPricePerThousand() float64 {
|
||||||
|
return FileSearchPrice
|
||||||
|
}
|
||||||
@@ -618,7 +618,6 @@ const LogsTable = () => {
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = other?.claude
|
let content = other?.claude
|
||||||
? renderClaudeModelPriceSimple(
|
? renderClaudeModelPriceSimple(
|
||||||
other.model_ratio,
|
other.model_ratio,
|
||||||
@@ -935,6 +934,13 @@ const LogsTable = () => {
|
|||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other?.user_group_ratio,
|
other?.user_group_ratio,
|
||||||
|
false,
|
||||||
|
1.0,
|
||||||
|
undefined,
|
||||||
|
other.web_search || false,
|
||||||
|
other.web_search_call_count || 0,
|
||||||
|
other.file_search || false,
|
||||||
|
other.file_search_call_count || 0,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -995,6 +1001,12 @@ const LogsTable = () => {
|
|||||||
other?.image || false,
|
other?.image || false,
|
||||||
other?.image_ratio || 0,
|
other?.image_ratio || 0,
|
||||||
other?.image_output || 0,
|
other?.image_output || 0,
|
||||||
|
other?.web_search || false,
|
||||||
|
other?.web_search_call_count || 0,
|
||||||
|
other?.web_search_price || 0,
|
||||||
|
other?.file_search || false,
|
||||||
|
other?.file_search_call_count || 0,
|
||||||
|
other?.file_search_price || 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const PersonalSetting = () => {
|
|||||||
email_verification_code: '',
|
email_verification_code: '',
|
||||||
email: '',
|
email: '',
|
||||||
self_account_deletion_confirmation: '',
|
self_account_deletion_confirmation: '',
|
||||||
|
original_password: '',
|
||||||
set_new_password: '',
|
set_new_password: '',
|
||||||
set_new_password_confirmation: '',
|
set_new_password_confirmation: '',
|
||||||
});
|
});
|
||||||
@@ -239,11 +240,24 @@ const PersonalSetting = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const changePassword = async () => {
|
const changePassword = async () => {
|
||||||
|
if (inputs.original_password === '') {
|
||||||
|
showError(t('请输入原密码!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inputs.set_new_password === '') {
|
||||||
|
showError(t('请输入新密码!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inputs.original_password === inputs.set_new_password) {
|
||||||
|
showError(t('新密码需要和原密码不一致!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
|
if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
|
||||||
showError(t('两次输入的密码不一致!'));
|
showError(t('两次输入的密码不一致!'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await API.put(`/api/user/self`, {
|
const res = await API.put(`/api/user/self`, {
|
||||||
|
original_password: inputs.original_password,
|
||||||
password: inputs.set_new_password,
|
password: inputs.set_new_password,
|
||||||
});
|
});
|
||||||
const { success, message } = res.data;
|
const { success, message } = res.data;
|
||||||
@@ -816,8 +830,8 @@ const PersonalSetting = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card style={{ marginTop: 10 }}>
|
<Card style={{ marginTop: 10 }}>
|
||||||
<Tabs type="line" defaultActiveKey="notification">
|
<Tabs type='line' defaultActiveKey='notification'>
|
||||||
<TabPane tab={t('通知设置')} itemKey="notification">
|
<TabPane tab={t('通知设置')} itemKey='notification'>
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<Typography.Text strong>{t('通知方式')}</Typography.Text>
|
<Typography.Text strong>{t('通知方式')}</Typography.Text>
|
||||||
<div style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
@@ -993,23 +1007,36 @@ const PersonalSetting = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
<TabPane tab={t('价格设置')} itemKey="price">
|
<TabPane tab={t('价格设置')} itemKey='price'>
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<Typography.Text strong>{t('接受未设置价格模型')}</Typography.Text>
|
<Typography.Text strong>
|
||||||
|
{t('接受未设置价格模型')}
|
||||||
|
</Typography.Text>
|
||||||
<div style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={notificationSettings.acceptUnsetModelRatioModel}
|
checked={
|
||||||
onChange={e => handleNotificationSettingChange('acceptUnsetModelRatioModel', e.target.checked)}
|
notificationSettings.acceptUnsetModelRatioModel
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleNotificationSettingChange(
|
||||||
|
'acceptUnsetModelRatioModel',
|
||||||
|
e.target.checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('接受未设置价格模型')}
|
{t('接受未设置价格模型')}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Typography.Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
|
<Typography.Text
|
||||||
{t('当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用')}
|
type='secondary'
|
||||||
|
style={{ marginTop: 8, display: 'block' }}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
'当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用',
|
||||||
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<Button type='primary' onClick={saveNotificationSettings}>
|
<Button type='primary' onClick={saveNotificationSettings}>
|
||||||
@@ -1118,6 +1145,16 @@ const PersonalSetting = () => {
|
|||||||
>
|
>
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<Input
|
<Input
|
||||||
|
name='original_password'
|
||||||
|
placeholder={t('原密码')}
|
||||||
|
type='password'
|
||||||
|
value={inputs.original_password}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleInputChange('original_password', value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
name='set_new_password'
|
name='set_new_password'
|
||||||
placeholder={t('新密码')}
|
placeholder={t('新密码')}
|
||||||
value={inputs.set_new_password}
|
value={inputs.set_new_password}
|
||||||
|
|||||||
@@ -317,6 +317,12 @@ export function renderModelPrice(
|
|||||||
image = false,
|
image = false,
|
||||||
imageRatio = 1.0,
|
imageRatio = 1.0,
|
||||||
imageOutputTokens = 0,
|
imageOutputTokens = 0,
|
||||||
|
webSearch = false,
|
||||||
|
webSearchCallCount = 0,
|
||||||
|
webSearchPrice = 0,
|
||||||
|
fileSearch = false,
|
||||||
|
fileSearchCallCount = 0,
|
||||||
|
fileSearchPrice = 0,
|
||||||
) {
|
) {
|
||||||
if (modelPrice !== -1) {
|
if (modelPrice !== -1) {
|
||||||
return i18next.t(
|
return i18next.t(
|
||||||
@@ -339,14 +345,17 @@ export function renderModelPrice(
|
|||||||
// Calculate effective input tokens (non-cached + cached with ratio applied)
|
// Calculate effective input tokens (non-cached + cached with ratio applied)
|
||||||
let effectiveInputTokens =
|
let effectiveInputTokens =
|
||||||
inputTokens - cacheTokens + cacheTokens * cacheRatio;
|
inputTokens - cacheTokens + cacheTokens * cacheRatio;
|
||||||
// Handle image tokens if present
|
// Handle image tokens if present
|
||||||
if (image && imageOutputTokens > 0) {
|
if (image && imageOutputTokens > 0) {
|
||||||
effectiveInputTokens = inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
|
effectiveInputTokens =
|
||||||
|
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
|
||||||
}
|
}
|
||||||
|
|
||||||
let price =
|
let price =
|
||||||
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
|
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
|
||||||
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
|
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
||||||
|
(webSearchCallCount / 1000) * webSearchPrice +
|
||||||
|
(fileSearchCallCount / 1000) * fileSearchPrice;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -391,9 +400,23 @@ export function renderModelPrice(
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{webSearch && webSearchCallCount > 0 && (
|
||||||
|
<p>
|
||||||
|
{i18next.t('Web搜索价格:${{price}} / 1K 次', {
|
||||||
|
price: webSearchPrice,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fileSearch && fileSearchCallCount > 0 && (
|
||||||
|
<p>
|
||||||
|
{i18next.t('文件搜索价格:${{price}} / 1K 次', {
|
||||||
|
price: fileSearchPrice,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p></p>
|
<p></p>
|
||||||
<p>
|
<p>
|
||||||
{cacheTokens > 0 && !image
|
{cacheTokens > 0 && !image && !webSearch && !fileSearch
|
||||||
? i18next.t(
|
? i18next.t(
|
||||||
'输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
'输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
||||||
{
|
{
|
||||||
@@ -407,31 +430,75 @@ export function renderModelPrice(
|
|||||||
total: price.toFixed(6),
|
total: price.toFixed(6),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: image && imageOutputTokens > 0
|
: image && imageOutputTokens > 0 && !webSearch && !fileSearch
|
||||||
? i18next.t(
|
? i18next.t(
|
||||||
'输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
'输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
||||||
{
|
{
|
||||||
nonImageInput: inputTokens - imageOutputTokens,
|
nonImageInput: inputTokens - imageOutputTokens,
|
||||||
imageInput: imageOutputTokens,
|
imageInput: imageOutputTokens,
|
||||||
imageRatio: imageRatio,
|
imageRatio: imageRatio,
|
||||||
price: inputRatioPrice,
|
price: inputRatioPrice,
|
||||||
completion: completionTokens,
|
completion: completionTokens,
|
||||||
compPrice: completionRatioPrice,
|
compPrice: completionRatioPrice,
|
||||||
ratio: groupRatio,
|
ratio: groupRatio,
|
||||||
total: price.toFixed(6),
|
total: price.toFixed(6),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: i18next.t(
|
: webSearch && webSearchCallCount > 0 && !image && !fileSearch
|
||||||
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
? i18next.t(
|
||||||
{
|
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} = ${{total}}',
|
||||||
input: inputTokens,
|
{
|
||||||
price: inputRatioPrice,
|
input: inputTokens,
|
||||||
completion: completionTokens,
|
price: inputRatioPrice,
|
||||||
compPrice: completionRatioPrice,
|
completion: completionTokens,
|
||||||
ratio: groupRatio,
|
compPrice: completionRatioPrice,
|
||||||
total: price.toFixed(6),
|
ratio: groupRatio,
|
||||||
},
|
webSearchCallCount,
|
||||||
)}
|
webSearchPrice,
|
||||||
|
total: price.toFixed(6),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: fileSearch && fileSearchCallCount > 0 && !image && !webSearch
|
||||||
|
? i18next.t(
|
||||||
|
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} = ${{total}}',
|
||||||
|
{
|
||||||
|
input: inputTokens,
|
||||||
|
price: inputRatioPrice,
|
||||||
|
completion: completionTokens,
|
||||||
|
compPrice: completionRatioPrice,
|
||||||
|
ratio: groupRatio,
|
||||||
|
fileSearchCallCount,
|
||||||
|
fileSearchPrice,
|
||||||
|
total: price.toFixed(6),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: webSearch && webSearchCallCount > 0 && fileSearch && fileSearchCallCount > 0 && !image
|
||||||
|
? i18next.t(
|
||||||
|
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} = ${{total}}',
|
||||||
|
{
|
||||||
|
input: inputTokens,
|
||||||
|
price: inputRatioPrice,
|
||||||
|
completion: completionTokens,
|
||||||
|
compPrice: completionRatioPrice,
|
||||||
|
ratio: groupRatio,
|
||||||
|
webSearchCallCount,
|
||||||
|
webSearchPrice,
|
||||||
|
fileSearchCallCount,
|
||||||
|
fileSearchPrice,
|
||||||
|
total: price.toFixed(6),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: i18next.t(
|
||||||
|
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
||||||
|
{
|
||||||
|
input: inputTokens,
|
||||||
|
price: inputRatioPrice,
|
||||||
|
completion: completionTokens,
|
||||||
|
compPrice: completionRatioPrice,
|
||||||
|
ratio: groupRatio,
|
||||||
|
total: price.toFixed(6),
|
||||||
|
},
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
|
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -448,33 +515,56 @@ export function renderLogContent(
|
|||||||
user_group_ratio,
|
user_group_ratio,
|
||||||
image = false,
|
image = false,
|
||||||
imageRatio = 1.0,
|
imageRatio = 1.0,
|
||||||
useUserGroupRatio = undefined
|
useUserGroupRatio = undefined,
|
||||||
|
webSearch = false,
|
||||||
|
webSearchCallCount = 0,
|
||||||
|
fileSearch = false,
|
||||||
|
fileSearchCallCount = 0,
|
||||||
) {
|
) {
|
||||||
const ratioLabel = useUserGroupRatio ? i18next.t('专属倍率') : i18next.t('分组倍率');
|
const ratioLabel = useUserGroupRatio
|
||||||
|
? i18next.t('专属倍率')
|
||||||
|
: i18next.t('分组倍率');
|
||||||
const ratio = useUserGroupRatio ? user_group_ratio : groupRatio;
|
const ratio = useUserGroupRatio ? user_group_ratio : groupRatio;
|
||||||
|
|
||||||
if (modelPrice !== -1) {
|
if (modelPrice !== -1) {
|
||||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||||
price: modelPrice,
|
price: modelPrice,
|
||||||
ratioType: ratioLabel,
|
ratioType: ratioLabel,
|
||||||
ratio
|
ratio,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (image) {
|
if (image) {
|
||||||
return i18next.t('模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}', {
|
return i18next.t(
|
||||||
modelRatio: modelRatio,
|
'模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}',
|
||||||
completionRatio: completionRatio,
|
{
|
||||||
imageRatio: imageRatio,
|
modelRatio: modelRatio,
|
||||||
ratioType: ratioLabel,
|
completionRatio: completionRatio,
|
||||||
ratio
|
imageRatio: imageRatio,
|
||||||
});
|
ratioType: ratioLabel,
|
||||||
|
ratio,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (webSearch) {
|
||||||
|
return i18next.t(
|
||||||
|
'模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次',
|
||||||
|
{
|
||||||
|
modelRatio: modelRatio,
|
||||||
|
completionRatio: completionRatio,
|
||||||
|
ratioType: ratioLabel,
|
||||||
|
ratio,
|
||||||
|
webSearchCallCount,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return i18next.t('模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}', {
|
return i18next.t(
|
||||||
modelRatio: modelRatio,
|
'模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
|
||||||
completionRatio: completionRatio,
|
{
|
||||||
ratioType: ratioLabel,
|
modelRatio: modelRatio,
|
||||||
ratio
|
completionRatio: completionRatio,
|
||||||
});
|
ratioType: ratioLabel,
|
||||||
|
ratio,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user