feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user