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.
343 lines
14 KiB
Go
343 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"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/setting/operation_setting"
|
|
"github.com/QuantumNous/new-api/tokenkit"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestResponsesUsageAccumulatorTerminalAccounting(t *testing.T) {
|
|
operation_setting.SetToolPriceForTest("responses_priced_fn", 5)
|
|
t.Cleanup(func() { operation_setting.DeleteToolPriceForTest("responses_priced_fn") })
|
|
for _, tc := range []struct {
|
|
eventType string
|
|
wantImages int
|
|
}{
|
|
{eventType: "response.completed", wantImages: 1},
|
|
{eventType: "response.done", wantImages: 1},
|
|
{eventType: "response.incomplete", wantImages: 1},
|
|
{eventType: "response.failed", wantImages: 1},
|
|
{eventType: "response.cancelled", wantImages: 1},
|
|
{eventType: "response.canceled", wantImages: 1},
|
|
} {
|
|
t.Run(tc.eventType, func(t *testing.T) {
|
|
info := &relaycommon.RelayInfo{OriginModelName: "gpt-5.1", StreamStatus: relaycommon.NewStreamStatus()}
|
|
accumulator := NewResponsesUsageAccumulator(info)
|
|
for _, item := range []dto.ResponsesOutput{
|
|
{Type: dto.BuildInCallWebSearchCall},
|
|
{Type: dto.BuildInCallFileSearchCall},
|
|
{Type: dto.BuildInCallFunctionCall, Name: "responses_priced_fn"},
|
|
{Type: dto.BuildInCallFunctionCall, Name: "responses_unpriced_fn"},
|
|
} {
|
|
accumulator.Observe(&dto.ResponsesStreamResponse{Type: dto.ResponsesOutputTypeItemDone, Item: &item}, nil)
|
|
}
|
|
image := dto.ResponsesOutput{ID: "image-1", Type: dto.ResponsesOutputTypeImageGenerationCall, Status: "completed", Result: "image-data"}
|
|
accumulator.Observe(&dto.ResponsesStreamResponse{Type: dto.ResponsesOutputTypeItemDone, Item: &image}, nil)
|
|
upstream := &dto.Usage{InputTokens: 20, OutputTokens: 5, TotalTokens: 25, InputTokensDetails: &dto.InputTokenDetails{CachedTokens: 4}}
|
|
upstream.BillingUsage = dto.NewOpenAIResponsesBillingUsage(upstream)
|
|
terminal := &dto.ResponsesStreamResponse{
|
|
Type: tc.eventType,
|
|
Response: &dto.OpenAIResponsesResponse{
|
|
Usage: upstream, Output: []dto.ResponsesOutput{image},
|
|
},
|
|
}
|
|
accumulator.Observe(terminal, nil)
|
|
accumulator.Observe(terminal, nil)
|
|
usage := accumulator.Finish()
|
|
assert.Equal(t, tc.eventType == "response.failed", info.StreamStatus.ResponseFailed())
|
|
assert.NotEmpty(t, info.StreamStatus.ResponseOutcome())
|
|
|
|
assert.Equal(t, 20, usage.PromptTokens)
|
|
assert.Equal(t, 5, usage.CompletionTokens)
|
|
assert.Equal(t, 25, usage.TotalTokens)
|
|
assert.Equal(t, 4, usage.PromptTokensDetails.CachedTokens)
|
|
require.NotNil(t, usage.BillingUsage)
|
|
assert.Equal(t, upstream.BillingUsage, usage.BillingUsage)
|
|
assert.NotSame(t, upstream.BillingUsage, usage.BillingUsage)
|
|
tools := info.ResponsesUsageInfo.BuiltInTools
|
|
for _, name := range []string{dto.BuildInToolWebSearchPreview, dto.BuildInToolFileSearch, "responses_priced_fn"} {
|
|
require.Contains(t, tools, name)
|
|
assert.Equal(t, 1, tools[name].CallCount)
|
|
}
|
|
assert.NotContains(t, tools, "responses_unpriced_fn")
|
|
require.Contains(t, tools, dto.BuildInToolImageGeneration)
|
|
assert.Equal(t, tc.wantImages, tools[dto.BuildInToolImageGeneration].CallCount)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponsesUsageAccumulatorInterruptedTextFallback(t *testing.T) {
|
|
for _, withUsage := range []bool{false, true} {
|
|
name := "disconnect without terminal usage"
|
|
if withUsage {
|
|
name = "failed response preserves native billing usage"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-4o"}}
|
|
info.SetEstimatePromptTokens(100)
|
|
accumulator := NewResponsesUsageAccumulator(info)
|
|
accumulator.Observe(&dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: "hello"}, nil)
|
|
if withUsage {
|
|
upstream := &dto.Usage{InputTokens: 20, InputTokensDetails: &dto.InputTokenDetails{CachedTokens: 4}}
|
|
upstream.BillingUsage = dto.NewOpenAIResponsesBillingUsage(upstream)
|
|
accumulator.Observe(&dto.ResponsesStreamResponse{Type: "response.failed", Response: &dto.OpenAIResponsesResponse{Usage: upstream}}, nil)
|
|
}
|
|
usage := accumulator.Finish()
|
|
assert.Equal(t, 1, usage.CompletionTokens)
|
|
if withUsage {
|
|
assert.Equal(t, 20, usage.PromptTokens)
|
|
assert.Equal(t, 21, usage.TotalTokens)
|
|
require.NotNil(t, usage.BillingUsage)
|
|
assert.Equal(t, dto.BillingUsageSourceOAIResponses, usage.BillingUsage.Source)
|
|
assert.True(t, usage.BillingUsage.Estimated)
|
|
canonical, ok := usage.BillingUsage.CanonicalUsage()
|
|
require.True(t, ok)
|
|
assert.Equal(t, 4, canonical.PromptTokensDetails.CachedTokens)
|
|
assert.Equal(t, 1, canonical.CompletionTokens)
|
|
} else {
|
|
assert.Equal(t, 100, usage.PromptTokens)
|
|
assert.Equal(t, 101, usage.TotalTokens)
|
|
}
|
|
accumulator.Observe(&dto.ResponsesStreamResponse{Type: "response.output_text.delta", Delta: " late output"}, nil)
|
|
assert.Equal(t, usage, accumulator.Finish())
|
|
assert.Equal(t, 1, usage.CompletionTokens)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestObserveResponsesOutcomeRecordsProtocolFacts(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
event string
|
|
wantOutcome relaycommon.ResponseOutcome
|
|
wantCode string
|
|
wantType string
|
|
wantIncompl string
|
|
}{
|
|
{"flat sse error", `{"type":"error","code":"context_length_exceeded","message":"too long"}`, relaycommon.ResponseOutcomeFailed, "context_length_exceeded", "", ""},
|
|
{"done with failed status", `{"type":"response.done","response":{"status":"failed","error":{"code":"invalid_api_key","type":"invalid_request_error","message":"bad key"}}}`, relaycommon.ResponseOutcomeFailed, "invalid_api_key", "invalid_request_error", ""},
|
|
{"incomplete keeps reason", `{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`, relaycommon.ResponseOutcomeIncomplete, "", "", "max_output_tokens"},
|
|
{"in progress is not terminal", `{"type":"response.created","response":{"status":"in_progress"}}`, relaycommon.ResponseOutcomeUnknown, "", "", ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var event dto.ResponsesStreamResponse
|
|
require.NoError(t, common.UnmarshalJsonStr(tc.event, &event))
|
|
info := &relaycommon.RelayInfo{StreamStatus: relaycommon.NewStreamStatus()}
|
|
ObserveResponsesOutcome(info, &event)
|
|
outcome := info.StreamStatus.OutcomeSnapshot()
|
|
assert.Equal(t, tc.wantOutcome, outcome.Response)
|
|
assert.Equal(t, tc.wantCode, outcome.ErrorCode)
|
|
assert.Equal(t, tc.wantType, outcome.ErrorType)
|
|
assert.Equal(t, tc.wantIncompl, outcome.IncompleteReason)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponsesUsageAccumulatorDisconnectBillsCompletedImage(t *testing.T) {
|
|
info := &relaycommon.RelayInfo{OriginModelName: "gpt-5.1", StreamStatus: relaycommon.NewStreamStatus()}
|
|
accumulator := NewResponsesUsageAccumulator(info)
|
|
accumulator.Observe(&dto.ResponsesStreamResponse{
|
|
Type: dto.ResponsesOutputTypeItemDone,
|
|
Item: &dto.ResponsesOutput{ID: "complete-image", Type: dto.ResponsesOutputTypeImageGenerationCall, Status: "completed", Result: "final-image-data"},
|
|
}, nil)
|
|
accumulator.Observe(&dto.ResponsesStreamResponse{
|
|
Type: dto.ResponsesOutputTypeItemDone,
|
|
Item: &dto.ResponsesOutput{ID: "partial-image", Type: dto.ResponsesOutputTypeImageGenerationCall, Status: "partial", Result: "partial-image-data"},
|
|
}, nil)
|
|
usage := accumulator.Finish()
|
|
assert.Zero(t, usage.TotalTokens)
|
|
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolImageGeneration)
|
|
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
|
|
accumulator.Finish()
|
|
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
|
|
}
|
|
|
|
func TestApplyResponsesUsageCopiesTokenDetails(t *testing.T) {
|
|
dst := &dto.Usage{}
|
|
src := &dto.Usage{
|
|
InputTokens: 11,
|
|
OutputTokens: 7,
|
|
TotalTokens: 18,
|
|
InputTokensDetails: &dto.InputTokenDetails{
|
|
CachedTokens: 3,
|
|
CachedCreationTokens: 2,
|
|
TextTokens: 6,
|
|
AudioTokens: 4,
|
|
ImageTokens: 5,
|
|
},
|
|
OutputTokensDetails: &dto.OutputTokenDetails{
|
|
TextTokens: 1,
|
|
AudioTokens: 2,
|
|
ImageTokens: 3,
|
|
ReasoningTokens: 4,
|
|
},
|
|
PromptCacheHitTokens: 3,
|
|
UsageSemantic: "openai",
|
|
UsageSource: "upstream",
|
|
}
|
|
|
|
ApplyResponsesUsage(dst, src)
|
|
|
|
assert.Equal(t, 11, dst.PromptTokens)
|
|
assert.Equal(t, 7, dst.CompletionTokens)
|
|
assert.Equal(t, 18, dst.TotalTokens)
|
|
require.NotNil(t, dst.InputTokensDetails)
|
|
assert.Equal(t, *src.InputTokensDetails, dst.PromptTokensDetails)
|
|
assert.Equal(t, src.InputTokensDetails, dst.InputTokensDetails)
|
|
assert.NotSame(t, src.InputTokensDetails, dst.InputTokensDetails)
|
|
assert.Equal(t, *src.OutputTokensDetails, dst.CompletionTokenDetails)
|
|
require.NotNil(t, dst.OutputTokensDetails)
|
|
assert.Equal(t, *src.OutputTokensDetails, *dst.OutputTokensDetails)
|
|
assert.NotSame(t, src.OutputTokensDetails, dst.OutputTokensDetails)
|
|
assert.Equal(t, 3, dst.PromptCacheHitTokens)
|
|
assert.Equal(t, "openai", dst.UsageSemantic)
|
|
assert.Equal(t, "upstream", dst.UsageSource)
|
|
}
|
|
|
|
func TestApplyResponsesUsageFallsBackToCompletionTokenDetails(t *testing.T) {
|
|
dst := &dto.Usage{}
|
|
src := &dto.Usage{
|
|
CompletionTokenDetails: dto.OutputTokenDetails{
|
|
ReasoningTokens: 9,
|
|
},
|
|
}
|
|
|
|
ApplyResponsesUsage(dst, src)
|
|
|
|
assert.Equal(t, 9, dst.CompletionTokenDetails.ReasoningTokens)
|
|
require.NotNil(t, dst.OutputTokensDetails)
|
|
assert.Equal(t, 9, dst.OutputTokensDetails.ReasoningTokens)
|
|
}
|
|
|
|
func TestApplyResponsesUsagePreservesBillingSnapshotAcrossPartialUpdates(t *testing.T) {
|
|
dst := &dto.Usage{}
|
|
src := &dto.Usage{
|
|
InputTokens: 20,
|
|
OutputTokens: 10,
|
|
TotalTokens: 30,
|
|
InputTokensDetails: &dto.InputTokenDetails{
|
|
CachedTokens: 3,
|
|
AudioTokens: 2,
|
|
},
|
|
CompletionTokenDetails: dto.OutputTokenDetails{AudioTokens: 4},
|
|
UsageSemantic: "openai",
|
|
UsageSource: "upstream",
|
|
}
|
|
src.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
|
|
ApplyResponsesUsage(dst, src)
|
|
ApplyResponsesUsage(dst, &dto.Usage{
|
|
OutputTokens: 12,
|
|
InputTokensDetails: &dto.InputTokenDetails{},
|
|
OutputTokensDetails: &dto.OutputTokenDetails{ReasoningTokens: 5},
|
|
})
|
|
|
|
assert.Equal(t, 20, dst.PromptTokens)
|
|
assert.Equal(t, 12, dst.CompletionTokens)
|
|
assert.Equal(t, 32, dst.TotalTokens)
|
|
assert.Equal(t, 3, dst.PromptTokensDetails.CachedTokens)
|
|
assert.Equal(t, 2, dst.PromptTokensDetails.AudioTokens)
|
|
assert.Equal(t, 4, dst.CompletionTokenDetails.AudioTokens)
|
|
assert.Equal(t, 5, dst.CompletionTokenDetails.ReasoningTokens)
|
|
require.NotNil(t, dst.OutputTokensDetails)
|
|
assert.Equal(t, dst.CompletionTokenDetails, *dst.OutputTokensDetails)
|
|
assert.Equal(t, "openai", dst.UsageSemantic)
|
|
assert.Equal(t, "upstream", dst.UsageSource)
|
|
require.NotNil(t, dst.BillingUsage)
|
|
assert.Equal(t, src.BillingUsage, dst.BillingUsage)
|
|
assert.NotSame(t, src.BillingUsage, dst.BillingUsage)
|
|
}
|
|
|
|
func TestResponsesUsageAccumulatorMissingUsageEstimation(t *testing.T) {
|
|
const model = "gpt-4o"
|
|
const summary = "Inspect the repository before editing."
|
|
const arguments = `{"command":["bash","-lc","ls"]}`
|
|
inProgress := &dto.OpenAIResponsesResponse{Status: []byte(`"in_progress"`)}
|
|
for _, tc := range []struct {
|
|
name string
|
|
events []dto.ResponsesStreamResponse
|
|
wantPrompt int
|
|
wantCompletion int
|
|
}{
|
|
{
|
|
name: "tool call stream cut before terminal usage",
|
|
events: []dto.ResponsesStreamResponse{
|
|
{Type: "response.created", Response: inProgress},
|
|
{Type: "response.reasoning_summary_text.delta", Delta: summary},
|
|
{Type: "response.function_call_arguments.delta", Delta: arguments},
|
|
{Type: dto.ResponsesOutputTypeItemDone, Item: &dto.ResponsesOutput{Type: dto.BuildInCallFunctionCall, Name: "shell"}},
|
|
},
|
|
wantPrompt: 100,
|
|
wantCompletion: tokenkit.Count(model, summary+arguments),
|
|
},
|
|
{
|
|
name: "created only then disconnect bills the prompt",
|
|
events: []dto.ResponsesStreamResponse{
|
|
{Type: "response.created", Response: inProgress},
|
|
},
|
|
wantPrompt: 100,
|
|
},
|
|
{
|
|
name: "incomplete without usage bills the prompt",
|
|
events: []dto.ResponsesStreamResponse{
|
|
{Type: "response.created", Response: inProgress},
|
|
{Type: "response.incomplete", Response: &dto.OpenAIResponsesResponse{Status: []byte(`"incomplete"`)}},
|
|
},
|
|
wantPrompt: 100,
|
|
},
|
|
{
|
|
name: "completed without usage estimates from terminal output",
|
|
events: []dto.ResponsesStreamResponse{
|
|
{Type: "response.completed", Response: &dto.OpenAIResponsesResponse{
|
|
Status: []byte(`"completed"`),
|
|
Output: []dto.ResponsesOutput{{
|
|
Type: "message",
|
|
Role: "assistant",
|
|
Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "final answer"}},
|
|
}},
|
|
}},
|
|
},
|
|
wantPrompt: 100,
|
|
wantCompletion: tokenkit.Count(model, "final answer"),
|
|
},
|
|
{
|
|
name: "explicit failure without usage bills nothing",
|
|
events: []dto.ResponsesStreamResponse{
|
|
{Type: "response.created", Response: inProgress},
|
|
{Type: "response.failed", Response: &dto.OpenAIResponsesResponse{Status: []byte(`"failed"`)}},
|
|
},
|
|
},
|
|
{
|
|
name: "flat error event bills nothing",
|
|
events: []dto.ResponsesStreamResponse{
|
|
{Type: "response.created", Response: inProgress},
|
|
{Type: "error", Code: "server_error"},
|
|
},
|
|
},
|
|
{
|
|
name: "no upstream events bills nothing",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
info := &relaycommon.RelayInfo{
|
|
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model},
|
|
StreamStatus: relaycommon.NewStreamStatus(),
|
|
}
|
|
info.SetEstimatePromptTokens(100)
|
|
accumulator := NewResponsesUsageAccumulator(info)
|
|
for i := range tc.events {
|
|
accumulator.Observe(&tc.events[i], nil)
|
|
}
|
|
usage := accumulator.Finish()
|
|
assert.Equal(t, tc.wantPrompt, usage.PromptTokens)
|
|
assert.Equal(t, tc.wantCompletion, usage.CompletionTokens)
|
|
assert.Equal(t, tc.wantPrompt+tc.wantCompletion, usage.TotalTokens)
|
|
})
|
|
}
|
|
}
|