mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
* feat(channels): convert Claude and Gemini requests to OpenAI Responses on Advanced Custom channels
Advanced Custom channels could convert Claude Messages and Gemini
generateContent requests only to OpenAI Chat. Two converters,
claude_messages_to_openai_responses and
gemini_generate_content_to_openai_responses, now send them to an OpenAI
Responses upstream and convert the response back to the client's
format. The channel form offers them with the /v1/responses upstream
path and bearer auth by default, and the backend validates their
incoming paths like the existing converters. The IDs keep the values the
relay already uses for these conversions.
* feat(moonshot): forward Responses requests to Kimi unchanged
Moonshot channels answered /v1/responses with "not implemented". They
now forward the request body unchanged to {base}/v1/responses. Kimi
serves only kimi-k3 on that endpoint today; other models are rejected
by the upstream, not by the gateway.
* fix(relay): bill completed image_generation calls when a Responses request ends failed or incomplete
An image_generation call that completed before a response.failed,
response.incomplete, or cancelled terminal had already reached the
client, but the stream accumulator reset the image count to zero on
those terminals, and the non-stream handler skipped image outputs when
the response status was failed, cancelled, or incomplete. Completed
images now bill on every terminal, as they already did when the client
disconnected; image items that never completed still bill nothing.
* feat(relay): let channels encode hosted web search and bill vendor-reported search counts
Converted requests always encoded hosted web search the OpenAI way:
web_search_options on Chat and the native hosted tool on Responses,
Claude, and Gemini. Zhipu and DashScope compatible-mode Chat take their
own search fields, and the search counts that Azure, xAI, and DashScope
report in usage were ignored; only web_search_call items were billed.
relaykit gains a WebSearchEncoder option that a channel adaptor sets in
Init; without one, the default encodings stay. Zhipu sends the GLM
web_search tool with search_std, and Gemini clients on Zhipu now convert
through Chat. DashScope compatible-mode Chat sends enable_search and
search_options with the default turbo strategy and maps allowed domains
to assigned_site_list (up to 25 sites). DashScope Anthropic-compatible
requests that carry a web search tool get the cc_entrypoint system
marker DashScope requires before it searches. Zhipu and DashScope Chat
responses report no search usage, so a forced search bills one call,
which is dropped again if the request retries on another channel; a
Zhipu non-stream response that returns search results also bills one.
Several hosted web search definitions on a Chat target now drop the
extras with a diagnostic instead of failing the request.
Vendor-reported counts replace the counted web_search_call items under
the vendor's price key: Azure tool_usage.web_search.num_requests as
bing_web_search, xAI server_side_tool_usage_details (web_search_calls as
web_search, priced for grok models, plus x_search posts and profiles),
and DashScope usage.x_tools on Responses and server_tool_use on its
Anthropic endpoint as search_strategy_agent. Claude server_tool_use also
counts web_fetch, code_execution, and tool_search, which bill only once
an operator prices them. Tool names with a built-in price and these
Claude server tools are reserved, so a client function of the same name
is never billed under them. The Claude and Gemini search counts move
from gin context keys into the per-request tool counts, and every tool
count is capped at 10,000 at settlement.
Built-in prices are seeded for these keys in each vendor's list currency
(Zhipu and DashScope in CNY, Azure and xAI in USD) and listed on the
tool price settings page. DashScope Responses requests move to
/compatible-mode/v1/responses, because the old /api/v2/apps/protocols
endpoint is no longer maintained.
173 lines
6.4 KiB
Go
173 lines
6.4 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{}}
|
|
}
|
|
|
|
// Observe feeds one decoded stream event. raw is the same event's wire bytes;
|
|
// the vendor tool-usage reader runs on it once, at the terminal event.
|
|
func (a *ResponsesUsageAccumulator) Observe(event *dto.ResponsesStreamResponse, raw []byte) {
|
|
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))
|
|
}
|
|
}
|
|
// Vendor counts are cumulative on the terminal event and replace the
|
|
// web_search_call items counted from output_item.done.
|
|
a.info.ApplyVendorToolUsage(raw)
|
|
if a.imageCommitted {
|
|
return
|
|
}
|
|
// Images that completed before any terminal, failed ones included,
|
|
// were delivered and stay billable; Observe still skips unfinished
|
|
// image items.
|
|
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, so completed tool usage is retained 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
|
|
}
|
|
}
|