feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+11 -1
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"strings"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -39,6 +40,13 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
if err := relayconvert.ApplyClaudeThinkingModel(request, info); err != nil {
return nil, err
}
// A model reasoning suffix replaces the client's thinking config, but
// block_binding is a conversation control, not a reasoning setting. Keep
// the client's value when the rendered mode is still adaptive.
if original, ok := info.Request.(*dto.ClaudeRequest); ok && original.Thinking != nil &&
request.Thinking != nil && request.Thinking.Type == "adaptive" && len(request.Thinking.BlockBinding) == 0 {
request.Thinking.BlockBinding = original.Thinking.BlockBinding
}
if request.MaxTokens == nil {
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
request.MaxTokens = &defaultMaxTokens
@@ -96,7 +104,9 @@ func shouldAppendClaudeBetaQuery(info *relaycommon.RelayInfo) bool {
func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
// common headers operation
anthropicBeta := c.Request.Header.Get("anthropic-beta")
// A client may send several anthropic-beta lines; the API reads one
// comma-separated list, so forward all of them.
anthropicBeta := strings.Join(c.Request.Header.Values("anthropic-beta"), ",")
if anthropicBeta != "" {
req.Set("anthropic-beta", anthropicBeta)
}
+111
View File
@@ -1,6 +1,8 @@
package claude
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -74,6 +76,35 @@ func TestConvertClaudeRequestPreservesNativeClaudeCodeThinking(t *testing.T) {
assert.Empty(t, info.ConversionDiagnostics())
}
func TestConvertClaudeRequestPreservesMessageOutputConfig(t *testing.T) {
body := `{"model":"claude-opus-5-5","max_tokens":64,"output_config":{"effort":"medium"},"messages":[` +
`{"role":"user","content":"summary"},` +
`{"role":"system","content":[],"output_config":{"effort":"high"}},` +
`{"role":"assistant","content":"done"}]}`
var req dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(body, &req))
info := &relaycommon.RelayInfo{
OriginModelName: req.Model,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: req.Model,
},
}
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, &req)
require.NoError(t, err)
encoded, err := common.Marshal(out)
require.NoError(t, err)
var upstream struct {
Messages []map[string]any `json:"messages"`
}
require.NoError(t, common.Unmarshal(encoded, &upstream))
require.Len(t, upstream.Messages, 3)
assert.Equal(t, map[string]any{"effort": "high"}, upstream.Messages[1]["output_config"])
assert.NotContains(t, upstream.Messages[0], "output_config")
assert.NotContains(t, upstream.Messages[2], "output_config")
}
func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -127,6 +158,86 @@ func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
}
func TestConvertClaudeRequestForwardsThinkingBlockBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
model string
thinking string
wantThinking string
}{
{
name: "native request keeps every thinking field",
model: "claude-opus-5-5",
thinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"},"future_field":{"a":1}}`,
wantThinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"},"future_field":{"a":1}}`,
},
{
name: "effort suffix re-renders adaptive and keeps block_binding",
model: "claude-opus-5-5-high",
thinking: `{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
wantThinking: `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
},
{
name: "suffix rendering between_tools does not carry block_binding",
model: "claude-sonnet-5-5-none",
thinking: `{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`,
wantThinking: `{"type":"between_tools"}`,
},
{
name: "request without block_binding gets none",
model: "claude-opus-5-5-high",
thinking: `{"type":"adaptive"}`,
wantThinking: `{"type":"adaptive","display":"summarized"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
body := `{"model":"` + tt.model + `","max_tokens":1024,"thinking":` + tt.thinking +
`,"messages":[{"role":"user","content":"hi"}]}`
var original dto.ClaudeRequest
require.NoError(t, common.UnmarshalJsonStr(body, &original))
info := &relaycommon.RelayInfo{
OriginModelName: tt.model,
Request: &original,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
}
// Same steps as the Claude handler before the adaptor converts.
outbound, err := common.DeepCopy(&original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(nil, info, outbound))
out, err := (&Adaptor{}).ConvertClaudeRequest(c, info, outbound)
require.NoError(t, err)
encoded, err := common.Marshal(out)
require.NoError(t, err)
var upstream struct {
Thinking json.RawMessage `json:"thinking"`
}
require.NoError(t, common.Unmarshal(encoded, &upstream))
assert.JSONEq(t, tt.wantThinking, string(upstream.Thinking))
})
}
}
func TestSetupRequestHeaderForwardsEveryAnthropicBetaValue(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Add("anthropic-beta", "fast-mode-2026-02-01")
c.Request.Header.Add("anthropic-beta", "thinking-binding-controls-2026-08-01")
info := &relaycommon.RelayInfo{
OriginModelName: "claude-opus-5-5",
ChannelMeta: &relaycommon.ChannelMeta{ApiKey: "sk-test"},
}
header := http.Header{}
require.NoError(t, (&Adaptor{}).SetupRequestHeader(c, &header, info))
assert.Equal(t, []string{"fast-mode-2026-02-01,thinking-binding-controls-2026-08-01"}, header.Values("anthropic-beta"))
}
func geminiToClaudeInfo() *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet",
+70 -33
View File
@@ -1,6 +1,7 @@
package claude
import (
"cmp"
"fmt"
"io"
"net/http"
@@ -89,7 +90,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
var claudeResponse dto.ClaudeResponse
err := common.UnmarshalJsonStr(data, &claudeResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" {
@@ -122,7 +123,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
data = patchClaudeMessageDeltaUsageData(data, buildMessageDeltaPatchUsage(&claudeResponse, claudeInfo))
}
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
state, err := claudeToChatStreamState(info)
@@ -138,14 +139,14 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
if response == nil {
return nil
}
err = helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "send_stream_response_failed: "+err.Error())
logger.LogError(c, common.LogText("send_stream_response_failed: %s", err.Error()))
}
} else if info.RelayFormat == types.RelayFormatGemini {
state, err := claudeToGeminiStreamState(info)
@@ -159,7 +160,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if !FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo) {
return nil
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
return sendErr
}
@@ -221,38 +222,77 @@ func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResu
return nil
}
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
func countClaudeStreamBillableTools(info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
if claudeResponse == nil {
return
}
if claudeResponse.Type == "content_block_start" &&
claudeResponse.ContentBlock != nil &&
claudeResponse.ContentBlock.Type == "tool_use" {
info.CountBillableToolCall(dto.BuildInCallToolUse, claudeResponse.ContentBlock.Name)
if claudeResponse.Type == "content_block_start" && claudeResponse.ContentBlock != nil {
countClaudeContentBlockTools(info, claudeResponse.ContentBlock)
}
if claudeResponse.Type == "message_delta" &&
claudeResponse.Usage != nil &&
claudeResponse.Usage.ServerToolUse != nil &&
claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
if claudeResponse.Type == "message_delta" && claudeResponse.Usage != nil {
// message_delta usage is cumulative, so the last report wins.
recordClaudeServerToolUse(info, claudeResponse.Usage.ServerToolUse)
}
}
// countClaudeContentBlockTools counts the billable tool calls of one content
// block. On a channel whose upstream prices search itself
// (WebSearchBillingKey), every web_search_tool_result that carries results
// bills one search: usage.server_tool_use replaces that count when reported,
// and it stands when a stream ends before message_delta.
func countClaudeContentBlockTools(info *relaycommon.RelayInfo, block *dto.ClaudeMediaMessage) {
switch block.Type {
case "tool_use":
info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name)
case "web_search_tool_result":
if _, delivered := block.Content.([]any); delivered && info.WebSearchBillingKey != "" {
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
}
}
}
// recordClaudeServerToolUse bills the server tool requests Anthropic reports
// in usage.server_tool_use. Searches and fetches use the channel's own keys
// when its adaptor sets them; otherwise web_fetch, code_execution, and
// tool_search have no built-in price and bill only once an operator prices
// them.
func recordClaudeServerToolUse(info *relaycommon.RelayInfo, serverToolUse *dto.ClaudeServerToolUse) {
if info == nil || serverToolUse == nil {
return
}
for name, count := range map[string]int{
cmp.Or(info.WebSearchBillingKey, dto.BuildInToolWebSearch): serverToolUse.WebSearchRequests,
cmp.Or(info.WebFetchBillingKey, "web_fetch"): serverToolUse.WebFetchRequests,
"code_execution": serverToolUse.CodeExecutionRequests,
"tool_search": serverToolUse.ToolSearchRequests,
} {
if count > 0 {
info.SetBillableToolCount(name, count)
}
}
}
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo) {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
}
if claudeInfo.Usage.CompletionTokens == 0 || !claudeInfo.Done {
// Claude input_tokens excludes cache reads and writes, so a fully cached
// prompt reports zero input; the prompt is uncounted only when the total
// with cache is zero as well. Zero output is uncounted only when content
// was streamed, so a refusal before any output keeps its zeros.
missingInputUsage := buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage).PromptTokens == 0
missingOutputUsage := claudeInfo.Usage.CompletionTokens == 0 && claudeInfo.ResponseText.Len() > 0
if missingInputUsage || missingOutputUsage || !claudeInfo.Done {
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
common.SysLog(common.LogText("claude response usage is not complete, maybe upstream error"))
}
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
// Each tool_use block costs about 40 output tokens beyond its name and JSON
// input (measured on Claude Haiku 4.5 and Sonnet 5 agent turns).
fallback.CompletionTokens += claudeInfo.ToolUseCount * 40
if missingOutputUsage ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
if missingInputUsage {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
@@ -270,23 +310,23 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, openAIUsage)
err := helper.ObjectData(c, response)
if err != nil {
common.SysLog("send final response failed: " + err.Error())
common.SysLog(common.LogText("send final response failed: %s", err.Error()))
}
}
helper.Done(c)
} else if info.RelayFormat == types.RelayFormatGemini {
state, err := claudeToGeminiStreamState(info)
if err != nil {
common.SysLog("error creating Gemini stream state: " + err.Error())
common.SysLog(common.LogText("error creating Gemini stream state: %s", err.Error()))
return
}
results, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
common.SysLog("error finalizing Gemini stream response: " + err.Error())
common.SysLog(common.LogText("error finalizing Gemini stream response: %s", err.Error()))
return
}
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
common.SysLog("send final Gemini stream response failed: " + sendErr.Error())
common.SysLog(common.LogText("send final Gemini stream response failed: %s", sendErr.Error()))
}
}
}
@@ -387,14 +427,11 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
}
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
for i := range claudeResponse.Content {
countClaudeContentBlockTools(info, &claudeResponse.Content[i])
}
for _, block := range claudeResponse.Content {
if block.Type == "tool_use" {
info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name)
}
if claudeResponse.Usage != nil {
recordClaudeServerToolUse(info, claudeResponse.Usage.ServerToolUse)
}
service.IOCopyBytesGracefully(c, httpResp, responseData)
+98
View File
@@ -1,15 +1,20 @@
package claude
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/tokenkit"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -19,6 +24,47 @@ func commonPointer[T any](value T) *T {
return &value
}
func TestClaudeStreamFinalUsageEstimatesOnlyUncountedUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name, startUsage, deltaUsage, stopReason string // no deltaUsage: the stream broke before message_delta
streamText bool
wantInput, wantOutput int
wantLocalCount bool
}{
{name: "cache-only refusal keeps zeros", startUsage: `{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":20000,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20000}}`, deltaUsage: `{"output_tokens":0}`, stopReason: "refusal"},
{name: "broken stream keeps cache-only input", startUsage: `{"input_tokens":0,"output_tokens":1,"cache_read_input_tokens":50000}`, streamText: true, wantOutput: 2, wantLocalCount: true},
{name: "zeros after streamed text are estimated", startUsage: `{"input_tokens":0,"output_tokens":0}`, deltaUsage: `{"output_tokens":0}`, stopReason: "end_turn", streamText: true, wantInput: 1200, wantOutput: 2, wantLocalCount: true},
{name: "zero delta input keeps start input", startUsage: `{"input_tokens":1000,"output_tokens":1}`, deltaUsage: `{"input_tokens":0,"output_tokens":50}`, stopReason: "end_turn", streamText: true, wantInput: 1000, wantOutput: 50},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
info := &relaycommon.RelayInfo{RelayFormat: types.RelayFormatClaude, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-opus-4-8"}}
info.SetEstimatePromptTokens(1200)
state := &ClaudeResponseInfo{Usage: &dto.Usage{}}
start := fmt.Sprintf(`{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-4-8","usage":%s}}`, tc.startUsage)
require.Nil(t, HandleStreamResponseData(c, info, state, start))
if tc.streamText {
require.Nil(t, HandleStreamResponseData(c, info, state, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`))
}
if tc.deltaUsage != "" {
delta := fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":%q},"usage":%s}`, tc.stopReason, tc.deltaUsage)
require.Nil(t, HandleStreamResponseData(c, info, state, delta))
require.Nil(t, HandleStreamResponseData(c, info, state, `{"type":"message_stop"}`))
}
HandleStreamFinalResponse(c, info, state)
assert.Equal(t, tc.wantInput, state.Usage.PromptTokens)
assert.Equal(t, tc.wantOutput, state.Usage.CompletionTokens)
assert.Equal(t, tc.wantLocalCount, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
canonical, ok := state.Usage.BillingUsage.CanonicalUsage()
require.True(t, ok)
assert.Equal(t, tc.wantInput, canonical.PromptTokens, "settlement must bill the same input")
assert.Equal(t, tc.wantOutput, canonical.CompletionTokens)
})
}
}
func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
tests := []struct {
name string
@@ -228,6 +274,33 @@ func TestFormatClaudeResponseInfo_ContentBlockDelta(t *testing.T) {
}
}
// A stream cut off during an agent tool call must still estimate the call's
// output tokens when the final usage never arrives.
func TestClaudeStreamFallbackCountsInterruptedToolUse(t *testing.T) {
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{}}
events := []*dto.ClaudeResponse{
{Type: "content_block_start", ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: "toolu_1", Name: "read_file"}},
{Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: commonPointer(`{"path":`)}},
{Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: commonPointer(`"main.go"}`)}},
}
for _, event := range events {
require.True(t, FormatClaudeResponseInfo(event, nil, claudeInfo))
}
require.Equal(t, `read_file{"path":"main.go"}`, claudeInfo.ResponseText.String())
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-sonnet-5"},
}
info.SetEstimatePromptTokens(100)
HandleStreamFinalResponse(c, info, claudeInfo)
// Text estimate plus the per-tool_use framing the upstream bills.
assert.Equal(t, tokenkit.Estimate("claude-sonnet-5", `read_file{"path":"main.go"}`)+40, claudeInfo.Usage.CompletionTokens)
assert.Equal(t, 100, claudeInfo.Usage.PromptTokens)
}
func TestBuildOpenAIStyleUsageFromClaudeUsage(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
@@ -400,3 +473,28 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighE
require.Nil(t, claudeRequest.TopP)
require.Nil(t, claudeRequest.TopK)
}
func TestClaudeResponsePassesInputTransformationsThrough(t *testing.T) {
gin.SetMode(gin.TestMode)
info := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "claude-opus-5-5"},
}
transformations := `"input_transformations":[{"type":"thinking_dropped","path":"messages.1.content.0","reason":"prefix_binding_mismatch"}]`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
body := `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5-5","content":[{"type":"text","text":"hi"}],` +
`"stop_reason":"end_turn",` + transformations + `,"usage":{"input_tokens":5,"output_tokens":1}}`
require.Nil(t, HandleClaudeResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, nil, []byte(body)))
assert.Equal(t, body, w.Body.String())
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
event := `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],` +
transformations + `,"usage":{"input_tokens":5,"output_tokens":1}}}`
require.Nil(t, HandleStreamResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, event))
assert.Contains(t, w.Body.String(), "data: "+event+"\n")
}
+2 -2
View File
@@ -85,7 +85,7 @@ func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *rel
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var claudeResponse dto.ClaudeResponse
if err := common.UnmarshalJsonStr(data, &claudeResponse); err != nil {
logger.LogError(c, "failed to unmarshal Claude stream event: "+err.Error())
logger.LogError(c, common.LogText("failed to unmarshal Claude stream event: %s", err.Error()))
if failResponsesStream(err) {
// A nil streamErr here is intentional: the protocol-level failure
// event was delivered, so only the scanner needs to stop.
@@ -120,7 +120,7 @@ func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *rel
info.UpstreamModelName = claudeResponse.Message.Model
}
FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo)
countClaudeStreamBillableTools(c, info, &claudeResponse)
countClaudeStreamBillableTools(info, &claudeResponse)
hostedEvents, consumed, err := hostedBridge.Convert(&claudeResponse, state)
if err != nil {
if failResponsesStream(err) {
+107 -6
View File
@@ -1,9 +1,12 @@
package claude
import (
"cmp"
"fmt"
"net/http/httptest"
"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/relaykit/types"
@@ -47,24 +50,23 @@ func TestHandleClaudeResponseDataCountsToolUse(t *testing.T) {
}
func TestCountClaudeStreamBillableToolsSetsWebSearchRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet"}
countClaudeStreamBillableTools(c, info, &dto.ClaudeResponse{
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
ServerToolUse: &dto.ClaudeServerToolUse{WebSearchRequests: 3},
},
})
assert.Equal(t, 3, c.GetInt("claude_web_search_requests"))
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "web_search")
assert.Equal(t, 3, info.ResponsesUsageInfo.BuiltInTools["web_search"].CallCount)
assert.Len(t, info.ResponsesUsageInfo.BuiltInTools, 1, "server tools reported as zero are not recorded")
operation_setting.SetToolPriceForTest("stream_fn", 2.0)
t.Cleanup(func() {
operation_setting.DeleteToolPriceForTest("stream_fn")
})
countClaudeStreamBillableTools(c, info, &dto.ClaudeResponse{
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "tool_use",
@@ -74,3 +76,102 @@ func TestCountClaudeStreamBillableToolsSetsWebSearchRequests(t *testing.T) {
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "stream_fn")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["stream_fn"].CallCount)
}
// usage.server_tool_use counts bill under their own keys; a channel that
// bills web search under a vendor key (Ali's Anthropic endpoint) re-keys
// web_search_requests.
func TestCountClaudeStreamBillableToolsServerToolKeys(t *testing.T) {
tests := []struct {
name string
billingKey string
fetchKey string
usage dto.ClaudeServerToolUse
want map[string]int
}{
{
name: "vendor web search key",
billingKey: "search_strategy_agent",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 2},
want: map[string]int{"search_strategy_agent": 2},
},
{
name: "vendor web search and web fetch keys",
billingKey: "deepseek_search",
fetchKey: "deepseek_fetch",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 1, WebFetchRequests: 2},
want: map[string]int{"deepseek_search": 1, "deepseek_fetch": 2},
},
{
name: "every server tool",
usage: dto.ClaudeServerToolUse{WebSearchRequests: 1, WebFetchRequests: 2, CodeExecutionRequests: 3, ToolSearchRequests: 4},
want: map[string]int{"web_search": 1, "web_fetch": 2, "code_execution": 3, "tool_search": 4},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet", WebSearchBillingKey: tt.billingKey, WebFetchBillingKey: tt.fetchKey}
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{ServerToolUse: &tt.usage},
})
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
assert.Equal(t, tt.want, counts)
})
}
}
// On a channel whose upstream prices search itself (DeepSeek), each delivered
// web_search_tool_result bills one search under the vendor key, so a stream cut
// before message_delta still pays for the searches that ran;
// usage.server_tool_use replaces that count when reported. Error results and
// channels without a vendor key count nothing from blocks.
func TestClaudeSearchResultBlocksBillVendorSearch(t *testing.T) {
gin.SetMode(gin.TestMode)
const results = `{"type":"web_search_tool_result","tool_use_id":"call_0","content":[{"type":"web_search_result","url":"https://example.com/"}]}`
const empty = `{"type":"web_search_tool_result","tool_use_id":"call_1","content":[]}`
const failed = `{"type":"web_search_tool_result","tool_use_id":"call_2","content":{"type":"web_search_tool_result_error","error_code":"unavailable"}}`
tests := []struct {
name, billingKey, blocks, usage string
want map[string]int
}{
{name: "stream cut before message_delta", billingKey: "deepseek_search", blocks: results + "," + empty + "," + failed,
want: map[string]int{"deepseek_search": 2}},
{name: "reported count replaces blocks", billingKey: "deepseek_search", blocks: results + "," + results,
usage: `{"input_tokens":1,"output_tokens":1,"server_tool_use":{"web_search_requests":3}}`, want: map[string]int{"deepseek_search": 3}},
{name: "no vendor key", blocks: results, want: map[string]int{}},
}
for _, tt := range tests {
for _, stream := range []bool{true, false} {
t.Run(fmt.Sprintf("%s stream=%t", tt.name, stream), func(t *testing.T) {
info := &relaycommon.RelayInfo{OriginModelName: "claude-sonnet-4-5", RelayFormat: types.RelayFormatClaude, WebSearchBillingKey: tt.billingKey}
var blocks []dto.ClaudeMediaMessage
require.NoError(t, common.UnmarshalJsonStr("["+tt.blocks+"]", &blocks))
if stream {
for i := range blocks {
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{Type: "content_block_start", ContentBlock: &blocks[i]})
}
if tt.usage != "" {
var usage dto.ClaudeUsage
require.NoError(t, common.UnmarshalJsonStr(tt.usage, &usage))
countClaudeStreamBillableTools(info, &dto.ClaudeResponse{Type: "message_delta", Usage: &usage})
}
} else {
usage := cmp.Or(tt.usage, `{"input_tokens":1,"output_tokens":1}`)
data := []byte(`{"type":"message","content":[` + tt.blocks + `],"usage":` + usage + `}`)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
require.Nil(t, HandleClaudeResponseData(c, info, &ClaudeResponseInfo{Usage: &dto.Usage{}}, nil, data))
}
counts := map[string]int{}
if info.ResponsesUsageInfo != nil {
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
}
assert.Equal(t, tt.want, counts)
})
}
}
}