mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
Token estimates badly undercounted agent requests. Claude tool definitions, Responses function calls and their outputs, Chat assistant tool calls, and Gemini system instructions, function calls, function responses, and tools never reached the counter; Chat tool schemas were counted as Go map syntax and Claude tool results as escaped JSON. Measured against upstream usage on 54 real Claude Code and Codex conversation windows, native prompt estimates were 31-93% low, and the Claude and Gemini completion fallbacks about 90% low because streamed tool calls were not counted. All of these now reach the counter. The completion fallbacks count tool names and arguments, plus 40 tokens per Claude tool_use block. The estimator weights are refitted per tokenizer: Claude before Opus 4.7, Claude 4.7 and later (about 30% more tokens for the same text), GPT, and Gemini. Prompt estimates now land within 1% bias and 3-4% mean error for each family. Token counting moves into tokenkit, an independent module like relaykit, so other gateways can use it. It also replaces the flat 520-token image estimate with each provider's sizing rules, checked against upstream usage on sizes outside the test table: OpenAI patch and tile rules per model and detail level (gpt-5.5 and later default to original detail, and their high detail is billed as measured, not as documented), Claude's two resolution tiers, and Gemini 3 media resolution and Gemini 2.x tiles. Chat image_url parts without detail no longer default to high in ParseContent, so they estimate as the upstream default auto; no converter sent that value upstream. tiktoken-go moves to v0.8.1, about twice as fast on ordinary text. It requires Go 1.26, so the root module and tokenkit move to Go 1.26; relaykit stays on 1.25.1. In every version BPE cost grows with the square of a pre-tokenized piece, so 100,000 Chinese characters without punctuation took 21.6 s to count during pre-consume. Count now cuts single-class runs longer than 1024 bytes, which leaves ordinary text unchanged and brings that input down to 106 ms. The encoder cache no longer grows with client-supplied model names.
169 lines
6.2 KiB
Go
169 lines
6.2 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/relayconvert"
|
|
"github.com/QuantumNous/new-api/tokenkit"
|
|
)
|
|
|
|
// ResponsesUsageAccumulator owns the accounting facts for one Responses stream.
|
|
// HTTP SSE and WebSocket transports feed the same events into it, then settle
|
|
// Finish's usage through the normal text billing path, including interrupted
|
|
// streams. Observe and Finish must be called by the same stream owner.
|
|
type ResponsesUsageAccumulator struct {
|
|
info *relaycommon.RelayInfo
|
|
usage *dto.Usage
|
|
outputText strings.Builder
|
|
imageCounter relaycommon.ImageGenerationCallCounter
|
|
imageCommitted bool
|
|
started bool
|
|
finished bool
|
|
}
|
|
|
|
func NewResponsesUsageAccumulator(info *relaycommon.RelayInfo) *ResponsesUsageAccumulator {
|
|
return &ResponsesUsageAccumulator{info: info, usage: &dto.Usage{}}
|
|
}
|
|
|
|
func (a *ResponsesUsageAccumulator) Observe(event *dto.ResponsesStreamResponse) {
|
|
if a == nil || event == nil || a.finished {
|
|
return
|
|
}
|
|
if event.Response != nil {
|
|
a.info.ObserveResponseModel(event.Response.Model)
|
|
}
|
|
a.started = true
|
|
ObserveResponsesOutcome(a.info, event)
|
|
switch event.Type {
|
|
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
|
if event.Response != nil {
|
|
ApplyResponsesUsage(a.usage, event.Response.Usage)
|
|
if a.outputText.Len() == 0 {
|
|
// Some upstreams carry the output only on the terminal event.
|
|
a.outputText.WriteString(relayconvert.ExtractOutputTextFromResponses(event.Response))
|
|
}
|
|
}
|
|
if a.imageCommitted {
|
|
return
|
|
}
|
|
failed := event.Type != "response.completed" && event.Type != "response.done"
|
|
if failed || (event.Response != nil && relaycommon.IsNonBillableResponsesStatus(event.Response.Status)) {
|
|
a.imageCounter.Reset()
|
|
} else if event.Response != nil {
|
|
for i := range event.Response.Output {
|
|
a.imageCounter.Observe(&event.Response.Output[i], &i)
|
|
}
|
|
}
|
|
a.imageCounter.Commit(a.info)
|
|
a.imageCommitted = true
|
|
case "response.output_text.delta", "response.function_call_arguments.delta",
|
|
"response.reasoning_summary_text.delta", "response.reasoning_text.delta", "response.refusal.delta":
|
|
// Every delta kind here is generated output that upstream bills as
|
|
// output tokens, so all of them feed the missing-usage estimate.
|
|
a.outputText.WriteString(event.Delta)
|
|
case dto.ResponsesOutputTypeItemDone:
|
|
if event.Item == nil {
|
|
return
|
|
}
|
|
switch event.Item.Type {
|
|
case dto.BuildInCallWebSearchCall, dto.BuildInCallFileSearchCall, dto.BuildInCallFunctionCall:
|
|
a.info.CountBillableToolCall(event.Item.Type, event.Item.Name)
|
|
case dto.ResponsesOutputTypeImageGenerationCall:
|
|
if !a.imageCommitted {
|
|
a.imageCounter.Observe(event.Item, event.OutputIndex)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *ResponsesUsageAccumulator) Finish() *dto.Usage {
|
|
if a.finished {
|
|
return a.usage
|
|
}
|
|
a.finished = true
|
|
// A final image item can already have reached the client before the stream
|
|
// disconnects. Explicit failed/incomplete terminals reset and commit zero in
|
|
// Observe; otherwise retain completed tool usage even without a terminal.
|
|
if !a.imageCommitted {
|
|
a.imageCounter.Commit(a.info)
|
|
a.imageCommitted = true
|
|
}
|
|
if a.usage.CompletionTokens == 0 {
|
|
if output := a.outputText.String(); output != "" {
|
|
a.usage.CompletionTokens = tokenkit.Count(a.info.GetUpstreamModelName(), output)
|
|
}
|
|
}
|
|
// Upstream bills the prompt as soon as it starts generating, so a stream
|
|
// that produced any event but no usage still owes its input tokens unless
|
|
// upstream reported an explicit failure.
|
|
billsPrompt := a.usage.CompletionTokens != 0 || (a.started && !a.info.StreamStatus.ResponseFailed())
|
|
if a.usage.PromptTokens == 0 && billsPrompt {
|
|
a.usage.PromptTokens = a.info.GetEstimatePromptTokens()
|
|
}
|
|
a.usage.TotalTokens = a.usage.PromptTokens + a.usage.CompletionTokens
|
|
if a.usage.BillingUsage != nil {
|
|
a.usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(a.usage.BillingUsage, a.usage.CompletionTokens)
|
|
}
|
|
return a.usage
|
|
}
|
|
|
|
// ObserveResponsesOutcome records the protocol outcome of one Responses event
|
|
// on the stream status for health classification. Only codes and types are
|
|
// kept; messages never leave the event.
|
|
func ObserveResponsesOutcome(info *relaycommon.RelayInfo, event *dto.ResponsesStreamResponse) {
|
|
if info == nil || info.StreamStatus == nil || event == nil {
|
|
return
|
|
}
|
|
var responseStatus string
|
|
if event.Response != nil {
|
|
_ = common.Unmarshal(event.Response.Status, &responseStatus)
|
|
}
|
|
switch {
|
|
case event.Type == "error" || event.Type == "response.failed" || event.Type == "response.error" || responseStatus == "failed":
|
|
code, errorType := event.Code, ""
|
|
if event.Response != nil {
|
|
if oaiErr := event.Response.GetOpenAIError(); oaiErr != nil {
|
|
if oaiErr.Code != nil {
|
|
code = fmt.Sprint(oaiErr.Code)
|
|
}
|
|
errorType = oaiErr.Type
|
|
}
|
|
}
|
|
info.StreamStatus.MarkFailed(code, errorType, 0)
|
|
case event.Type == "response.incomplete" || responseStatus == "incomplete":
|
|
reason := ""
|
|
if event.Response != nil && event.Response.IncompleteDetails != nil {
|
|
reason = event.Response.IncompleteDetails.Reason
|
|
}
|
|
info.StreamStatus.MarkIncomplete(reason)
|
|
case event.Type == "response.cancelled" || event.Type == "response.canceled" || responseStatus == "cancelled":
|
|
info.StreamStatus.MarkCancelled()
|
|
case event.Type == "response.completed" || event.Type == "response.done" || responseStatus == "completed":
|
|
info.StreamStatus.MarkCompleted()
|
|
}
|
|
}
|
|
|
|
func ApplyResponsesUsage(dst *dto.Usage, src *dto.Usage) {
|
|
if dst == nil || src == nil {
|
|
return
|
|
}
|
|
incoming := relayconvert.NormalizeResponsesUsage(src)
|
|
if src.InputTokensDetails != nil {
|
|
inputDetails := *src.InputTokensDetails
|
|
incoming.InputTokensDetails = &inputDetails
|
|
}
|
|
if src.OutputTokensDetails != nil {
|
|
incoming.CompletionTokenDetails = *src.OutputTokensDetails
|
|
}
|
|
incoming.PromptCacheHitTokens = src.PromptCacheHitTokens
|
|
dto.MergeUsageNonZero(dst, incoming)
|
|
outputDetails := dst.CompletionTokenDetails
|
|
if outputDetails != (dto.OutputTokenDetails{}) {
|
|
dst.OutputTokensDetails = &outputDetails
|
|
}
|
|
}
|