Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,11 @@ func UpdateUserSetting(c *gin.Context) {
NotifyType: req.QuotaWarningType,
QuotaWarningThreshold: req.QuotaWarningThreshold,
UpstreamModelUpdateNotifyEnabled: upstreamModelUpdateNotifyEnabled,
AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel,
// F-27: only admins may enable accept-unset-ratio. Self-service by a
// regular user bypasses the "model price not configured" gate, letting
// them use deliberately unpriced models at the default ratio (37.5x),
// which is both an availability-control and pricing-control bypass.
AcceptUnsetRatioModel: user.Role >= common.RoleAdminUser && req.AcceptUnsetModelRatioModel,
RecordIpLog: req.RecordIpLog,
}

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ require (
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/beorn7/perks v1.0.1 // indirect
Expand Down
6 changes: 6 additions & 0 deletions relay/channel/ali/rerank.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
CompletionTokens: 0,
TotalTokens: aliResponse.Usage.TotalTokens,
}
// F-53: fallback to the prompt estimate when the upstream omits usage so
// rerank requests are not billed as zero (F-26 residual for ali).
if usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 {
usage.PromptTokens = info.GetEstimatePromptTokens()
usage.TotalTokens = usage.PromptTokens
}
rerankResponse := dto.RerankResponse{
Results: aliResponse.Output.Results,
Usage: usage,
Expand Down
22 changes: 16 additions & 6 deletions relay/channel/aws/relay-aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,20 @@ func handleNovaRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor)
}

// 构造OpenAI格式响应
usage := dto.Usage{
PromptTokens: novaResp.Usage.InputTokens,
CompletionTokens: novaResp.Usage.OutputTokens,
TotalTokens: novaResp.Usage.TotalTokens,
}
if usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 {
// F-60: fall back to the estimate when the upstream omits usage so
// Nova requests are not billed as zero.
var text string
if len(novaResp.Output.Message.Content) > 0 {
text = novaResp.Output.Message.Content[0].Text
}
usage = *service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
}
response := dto.OpenAITextResponse{
Id: helper.GetResponseID(c),
Object: "chat.completion",
Expand All @@ -359,13 +373,9 @@ func handleNovaRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor)
},
FinishReason: "stop",
}},
Usage: dto.Usage{
PromptTokens: novaResp.Usage.InputTokens,
CompletionTokens: novaResp.Usage.OutputTokens,
TotalTokens: novaResp.Usage.TotalTokens,
},
Usage: usage,
}

c.JSON(http.StatusOK, response)
return nil, &response.Usage
return nil, &usage
}
9 changes: 9 additions & 0 deletions relay/channel/baidu/relay-baidu.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ func baiduEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *ht
return types.NewError(fmt.Errorf("%s", baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil
}
fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse)
// F-26 family: Baidu embedding responses may omit usage; without a
// fallback the settle charges 0 and the pre-consume is refunded, making
// embeddings free. Fall back to the local request estimate.
if fullTextResponse.Usage.TotalTokens == 0 && fullTextResponse.Usage.PromptTokens == 0 && fullTextResponse.Usage.CompletionTokens == 0 {
fullTextResponse.Usage = dto.Usage{
PromptTokens: info.GetEstimatePromptTokens(),
TotalTokens: info.GetEstimatePromptTokens(),
}
}
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
Expand Down
15 changes: 15 additions & 0 deletions relay/channel/claude/relay-claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,21 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
}
if claudeInfo.Usage.TotalTokens == 0 &&
claudeInfo.Usage.PromptTokens == 0 &&
claudeInfo.Usage.CompletionTokens == 0 {
// F-57: fall back to the estimate when the upstream omits usage so the
// response written to the client carries the same usage that
// settlement will bill (previously the fallback ran after the response
// was already serialized with zero usage).
var textBuilder strings.Builder
for _, block := range claudeResponse.Content {
if block.Text != nil && *block.Text != "" {
textBuilder.WriteString(*block.Text)
}
}
claudeInfo.Usage = service.ResponseText2Usage(c, textBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
}
var responseData []byte
switch info.RelayFormat {
case types.RelayFormatOpenAI:
Expand Down
117 changes: 65 additions & 52 deletions relay/channel/cohere/relay-cohere.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,75 +98,83 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
return 0, nil, nil
})
dataChan := make(chan string)
stopChan := make(chan bool)
// F-29: the reader goroutine must not block forever on an unbuffered
// channel when the client disconnects mid-stream (gin's c.Stream returns,
// leaving no receiver -> goroutine + upstream connection leak per request).
// A small buffer plus a done-select lets the reader exit promptly on
// client disconnect; stopChan send is non-blocking so EOF also cannot
// strand the goroutine.
dataChan := make(chan string, 64)
go func() {
for scanner.Scan() {
data := scanner.Text()
dataChan <- data
select {
case dataChan <- data:
case <-c.Request.Context().Done():
return
}
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
}
stopChan <- true
close(dataChan)
}()
helper.SetEventStreamHeaders(c)
isFirst := true
c.Stream(func(w io.Writer) bool {
select {
case data := <-dataChan:
if isFirst {
isFirst = false
info.FirstResponseTime = time.Now()
data, ok := <-dataChan
if !ok {
c.Render(-1, common.CustomEvent{Data: "data: [DONE]"})
return false
}
if isFirst {
isFirst = false
info.FirstResponseTime = time.Now()
}
data = strings.TrimSuffix(data, "\r")
var cohereResp CohereResponse
err := json.Unmarshal([]byte(data), &cohereResp)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
return true
}
var openaiResp dto.ChatCompletionsStreamResponse
openaiResp.Id = responseId
openaiResp.Created = createdTime
openaiResp.Object = "chat.completion.chunk"
openaiResp.Model = info.UpstreamModelName
if cohereResp.IsFinished {
finishReason := stopReasonCohere2OpenAI(cohereResp.FinishReason)
openaiResp.Choices = []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
Index: 0,
FinishReason: &finishReason,
},
}
data = strings.TrimSuffix(data, "\r")
var cohereResp CohereResponse
err := json.Unmarshal([]byte(data), &cohereResp)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
return true
if cohereResp.Response != nil {
usage.PromptTokens = cohereResp.Response.Meta.BilledUnits.InputTokens
usage.CompletionTokens = cohereResp.Response.Meta.BilledUnits.OutputTokens
}
var openaiResp dto.ChatCompletionsStreamResponse
openaiResp.Id = responseId
openaiResp.Created = createdTime
openaiResp.Object = "chat.completion.chunk"
openaiResp.Model = info.UpstreamModelName
if cohereResp.IsFinished {
finishReason := stopReasonCohere2OpenAI(cohereResp.FinishReason)
openaiResp.Choices = []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
Index: 0,
FinishReason: &finishReason,
},
}
if cohereResp.Response != nil {
usage.PromptTokens = cohereResp.Response.Meta.BilledUnits.InputTokens
usage.CompletionTokens = cohereResp.Response.Meta.BilledUnits.OutputTokens
}
} else {
openaiResp.Choices = []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Role: "assistant",
Content: &cohereResp.Text,
},
Index: 0,
} else {
openaiResp.Choices = []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Role: "assistant",
Content: &cohereResp.Text,
},
}
responseText += cohereResp.Text
Index: 0,
},
}
jsonStr, err := json.Marshal(openaiResp)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonStr)})
responseText += cohereResp.Text
}
jsonStr, err := json.Marshal(openaiResp)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
return true
case <-stopChan:
c.Render(-1, common.CustomEvent{Data: "data: [DONE]"})
return false
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonStr)})
return true
})
if usage.PromptTokens == 0 {
usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.GetEstimatePromptTokens())
Expand All @@ -190,6 +198,11 @@ func cohereHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
usage.PromptTokens = cohereResp.Meta.BilledUnits.InputTokens
usage.CompletionTokens = cohereResp.Meta.BilledUnits.OutputTokens
usage.TotalTokens = cohereResp.Meta.BilledUnits.InputTokens + cohereResp.Meta.BilledUnits.OutputTokens
if usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 {
// F-55: fall back to the estimate (prompt+completion+total) when the
// upstream omits usage, so generated output is not billed as zero.
usage = *service.ResponseText2Usage(c, cohereResp.Text, info.UpstreamModelName, info.GetEstimatePromptTokens())
}

var openaiResp dto.TextResponse
openaiResp.Id = cohereResp.ResponseId
Expand Down
10 changes: 8 additions & 2 deletions relay/channel/dify/relay-dify.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,17 @@ func difyHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respons
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
usage := difyResponse.MetaData.Usage
if usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 {
// F-58: fall back to the estimate when the upstream omits usage so
// non-stream Dify chat requests are not billed as zero.
usage = *service.ResponseText2Usage(c, difyResponse.Answer, info.UpstreamModelName, info.GetEstimatePromptTokens())
}
fullTextResponse := dto.OpenAITextResponse{
Id: difyResponse.ConversationId,
Object: "chat.completion",
Created: common.GetTimestamp(),
Usage: difyResponse.MetaData.Usage,
Usage: usage,
}
choice := dto.OpenAITextResponseChoice{
Index: 0,
Expand All @@ -297,5 +303,5 @@ func difyHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respons
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
c.Writer.Write(jsonResponse)
return &difyResponse.MetaData.Usage, nil
return &usage, nil
}
8 changes: 8 additions & 0 deletions relay/channel/mokaai/relay-mokaai.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ func mokaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *htt
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
// F-54: fallback to the prompt estimate when the upstream omits usage so
// embedding requests are not billed as zero (F-26 residual for mokaai).
if baiduResponse.Usage.TotalTokens == 0 &&
baiduResponse.Usage.PromptTokens == 0 &&
baiduResponse.Usage.CompletionTokens == 0 {
baiduResponse.Usage.PromptTokens = info.GetEstimatePromptTokens()
baiduResponse.Usage.TotalTokens = baiduResponse.Usage.PromptTokens
}
// if baiduResponse.ErrorMsg != "" {
// return &dto.OpenAIErrorWithStatusCode{
// Error: dto.OpenAIError{
Expand Down
6 changes: 6 additions & 0 deletions relay/channel/ollama/relay-ollama.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
data = append(data, dto.OpenAIEmbeddingResponseItem{Index: i, Object: "embedding", Embedding: emb})
}
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens: 0, TotalTokens: oResp.PromptEvalCount}
// F-54: fallback to the prompt estimate when the upstream omits prompt
// eval count so embedding requests are not billed as zero.
if usage.TotalTokens == 0 && usage.PromptTokens == 0 {
usage.PromptTokens = info.GetEstimatePromptTokens()
usage.TotalTokens = usage.PromptTokens
}
embResp := &dto.OpenAIEmbeddingResponse{Object: "list", Data: data, Model: info.UpstreamModelName, Usage: *usage}
out, _ := common.Marshal(embResp)
service.IOCopyBytesGracefully(c, resp, out)
Expand Down
12 changes: 12 additions & 0 deletions relay/channel/ollama/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
helper.SetEventStreamHeaders(c)
scanner := helper.NewStreamScanner(resp.Body)
usage := &dto.Usage{}
var responseText strings.Builder
var model = info.UpstreamModelName
var responseId = common.GetUUID()
var created = time.Now().Unix()
Expand Down Expand Up @@ -148,6 +149,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}},
}
if content != "" {
responseText.WriteString(content)
delta.Choices[0].Delta.SetContentString(content)
}
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
Expand Down Expand Up @@ -190,6 +192,11 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
_ = helper.StringData(c, string(data))
}
}
if usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 {
// F-62: apply the estimate before the usage frame is emitted so the
// client sees the same usage that settlement will bill.
usage = service.ResponseText2Usage(c, responseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
}
// emit usage frame
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
if data, err := common.Marshal(final); err == nil {
Expand Down Expand Up @@ -303,6 +310,11 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
}
created := toUnix(lastChunk.CreatedAt)
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
if usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 {
// F-62: fall back to the estimate when the upstream omits usage so
// non-stream ollama chat is not billed as zero.
usage = service.ResponseText2Usage(c, aggContent.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
}
content := aggContent.String()
finishReason := lastChunk.DoneReason
if finishReason == "" {
Expand Down
13 changes: 13 additions & 0 deletions relay/channel/openai/relay_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens
}
}
if usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 {
// F-56: fall back to the estimate when the upstream omits usage so
// non-stream Responses requests are not billed as zero.
var outText strings.Builder
for _, out := range responsesResponse.Output {
for _, c := range out.Content {
if c.Text != "" {
outText.WriteString(c.Text)
}
}
}
usage = *service.ResponseText2Usage(c, outText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
}
// Count actual tool invocations from Output (not tool declarations).
for _, output := range responsesResponse.Output {
switch output.Type {
Expand Down
8 changes: 8 additions & 0 deletions relay/channel/openai/relay_responses_compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
Expand Down Expand Up @@ -40,6 +41,13 @@ func OaiResponsesCompactionHandler(c *gin.Context, resp *http.Response) (*dto.Us
usage.PromptTokensDetails.CacheWriteTokens = compactResp.Usage.InputTokensDetails.CacheWriteTokens
}
}
// F-26 family: when the upstream compaction response omits usage, fall
// back to the local request estimate so the pre-consume is not fully
// refunded (free compaction).
if usage.TotalTokens == 0 {
usage.PromptTokens = common.GetContextKeyInt(c, constant.ContextKeyEstimatedTokens)
usage.TotalTokens = usage.PromptTokens
}

return &usage, nil
}
Loading