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

+3 -3
View File
@@ -2,8 +2,8 @@ package common
import (
"context"
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
@@ -46,13 +46,13 @@ func (info *RelayInfo) RecordConversionDiagnostics(ctx context.Context, diagnost
if len(info.conversionDiagnostics) >= maxConversionDiagnostics {
if !info.conversionDiagnosticsTruncated {
info.conversionDiagnosticsTruncated = true
logger.LogWarn(ctx, fmt.Sprintf("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
logger.LogWarn(ctx, common.LogText("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
}
continue
}
info.conversionDiagnosticKeys[key] = struct{}{}
info.conversionDiagnostics = append(info.conversionDiagnostics, diagnostic)
logger.LogWarn(ctx, fmt.Sprintf(
logger.LogWarn(ctx, common.LogText(
"conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q",
diagnostic.Code, diagnostic.Severity, diagnostic.From, diagnostic.To, diagnostic.Path, diagnostic.Message,
))
+23 -6
View File
@@ -56,10 +56,14 @@ type paramOverrideAuditRecorder struct {
type ConditionOperation struct {
Path string `json:"path"` // JSON路径
Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte
Mode string `json:"mode"` // full, prefix, suffix, contains, regex, gt, gte, lt, lte
Value any `json:"value"` // 匹配的值
Invert bool `json:"invert"` // 反选功能,true表示取反结果
PassMissingKey bool `json:"pass_missing_key"` // 未获取到json key时的行为
// Conditions are parsed per request. Reuse the lazily compiled pattern when
// pruning multiple objects; never compile before a matching key exists.
regex *regexp.Regexp
}
type ParamOperation struct {
@@ -633,8 +637,8 @@ func checkConditions(data []byte, contextJSON string, conditions []ConditionOper
return true, nil // 没有条件,直接通过
}
results := make([]bool, len(conditions))
for i, condition := range conditions {
result, err := checkSingleCondition(data, contextJSON, condition)
for i := range conditions {
result, err := checkSingleCondition(data, contextJSON, &conditions[i])
if err != nil {
return false, err
}
@@ -647,7 +651,7 @@ func checkConditions(data []byte, contextJSON string, conditions []ConditionOper
return lo.SomeBy(results, func(item bool) bool { return item }), nil
}
func checkSingleCondition(data []byte, contextJSON string, condition ConditionOperation) (bool, error) {
func checkSingleCondition(data []byte, contextJSON string, condition *ConditionOperation) (bool, error) {
// 处理负数索引
path := processNegativeIndex(data, condition.Path)
value := gjson.GetBytes(data, path)
@@ -668,7 +672,7 @@ func checkSingleCondition(data []byte, contextJSON string, condition ConditionOp
}
targetValue := gjson.ParseBytes(targetBytes)
result, err := compareGjsonValues(value, targetValue, strings.ToLower(condition.Mode))
result, err := condition.compareGjsonValues(value, targetValue)
if err != nil {
return false, fmt.Errorf("comparison failed for path %s: %v", condition.Path, err)
}
@@ -710,7 +714,8 @@ func processNegativeIndex(data []byte, path string) string {
}
// compareGjsonValues 直接比较两个gjson.Result,支持所有比较模式
func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) {
func (condition *ConditionOperation) compareGjsonValues(jsonValue, targetValue gjson.Result) (bool, error) {
mode := strings.ToLower(condition.Mode)
switch mode {
case "full":
return compareEqual(jsonValue, targetValue)
@@ -720,6 +725,18 @@ func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool,
return strings.HasSuffix(jsonValue.String(), targetValue.String()), nil
case "contains":
return strings.Contains(jsonValue.String(), targetValue.String()), nil
case "regex":
if targetValue.Type != gjson.String {
return false, fmt.Errorf("regex pattern must be a string")
}
if condition.regex == nil {
re, err := regexp.Compile(targetValue.String())
if err != nil {
return false, fmt.Errorf("invalid regex pattern: %w", err)
}
condition.regex = re
}
return condition.regex.MatchString(jsonValue.String()), nil
case "gt":
return compareNumeric(jsonValue, targetValue, "gt")
case "gte":
+143
View File
@@ -670,6 +670,149 @@ func TestApplyParamOverrideAppendObjectMergeOverride(t *testing.T) {
assertJSONEqual(t, `{"obj":{"a":2,"b":3}}`, string(out))
}
func TestApplyParamOverrideConditionRegex(t *testing.T) {
tests := []struct {
name string
input string
path string
pattern any
context map[string]any
invert bool
passMissing bool
wantMatch bool
wantError string
}{
{name: "anchors and alternation match", input: `{"model":"gpt-4o"}`, pattern: `^(gpt|claude)-`, wantMatch: true},
{name: "anchors reject other models", input: `{"model":"vendor/gpt-4o"}`, pattern: `^gpt-`},
{name: "unanchored pattern matches substring", input: `{"model":"vendor/gpt-4o"}`, pattern: `gpt-`, wantMatch: true},
{name: "Go inline flags", input: `{"model":"GPT-4o"}`, pattern: `(?i)^gpt-`, wantMatch: true},
{name: "escaped pattern", input: `{"model":"gpt-4.1"}`, pattern: `^gpt-\d\.\d$`, wantMatch: true},
{name: "invert match", input: `{"model":"gpt-4o"}`, pattern: `^gpt-`, invert: true},
{name: "invert nonmatch", input: `{"model":"claude-sonnet"}`, pattern: `^gpt-`, invert: true, wantMatch: true},
{name: "missing key does not match even inverted", input: `{}`, pattern: `.*`, invert: true},
{name: "missing key can pass", input: `{}`, pattern: `^gpt-`, passMissing: true, wantMatch: true},
{name: "missing key skips invalid pattern", input: `{}`, pattern: `[`},
{name: "missing key can pass with invalid pattern", input: `{}`, pattern: `[`, passMissing: true, wantMatch: true},
{name: "context fallback", input: `{}`, pattern: `^gpt-`, context: map[string]any{"model": "gpt-4o"}, wantMatch: true},
{name: "body takes priority over context", input: `{"model":"claude-sonnet"}`, pattern: `^gpt-`, context: map[string]any{"model": "gpt-4o"}},
{name: "negative array index", input: `{"messages":[{"role":"user"}]}`, path: "messages.-1.role", pattern: `^(user|system)$`, wantMatch: true},
{name: "string conversion matches other text comparisons", input: `{"model":123}`, pattern: `^\d+$`, wantMatch: true},
{name: "empty pattern matches empty string", input: `{"model":""}`, pattern: "", wantMatch: true},
{name: "invalid pattern returns error", input: `{"model":"gpt-4o"}`, pattern: "[", wantError: "invalid regex pattern"},
{name: "nonstring pattern returns error", input: `{"model":"123"}`, pattern: 123, wantError: "regex pattern must be a string"},
{name: "missing pattern returns error", input: `{"model":"gpt-4o"}`, wantError: "regex pattern must be a string"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := tt.path
if path == "" {
path = "model"
}
override := map[string]any{
"operations": []any{map[string]any{
"mode": "set", "path": "matched", "value": true,
"conditions": []any{map[string]any{
"path": path, "mode": "regex", "value": tt.pattern,
"invert": tt.invert, "pass_missing_key": tt.passMissing,
}},
}},
}
out, err := ApplyParamOverride([]byte(tt.input), override, tt.context)
if tt.wantError != "" {
require.ErrorContains(t, err, tt.wantError)
assert.ErrorContains(t, err, "comparison failed for path "+path)
return
}
require.NoError(t, err)
var result map[string]any
require.NoError(t, common2.Unmarshal(out, &result))
assert.Equal(t, tt.wantMatch, result["matched"] == true)
if !tt.wantMatch {
assert.JSONEq(t, tt.input, string(out))
}
})
}
}
func TestApplyParamOverrideRegexConditionLogic(t *testing.T) {
for _, tt := range []struct {
logic string
wantMatch bool
}{
{logic: "OR", wantMatch: true},
{logic: "AND", wantMatch: false},
} {
t.Run(tt.logic, func(t *testing.T) {
override := map[string]any{
"operations": []any{map[string]any{
"mode": "set", "path": "matched", "value": true, "logic": tt.logic,
"conditions": []any{
map[string]any{"path": "model", "mode": "regex", "value": `^gpt-`},
map[string]any{"path": "stream", "mode": "full", "value": true},
},
}},
}
out, err := ApplyParamOverride([]byte(`{"model":"gpt-4o","stream":false}`), override, nil)
require.NoError(t, err)
var result map[string]any
require.NoError(t, common2.Unmarshal(out, &result))
assert.Equal(t, tt.wantMatch, result["matched"] == true)
})
}
}
func TestApplyParamOverridePruneObjectsRegex(t *testing.T) {
condition := map[string]any{"path": "role", "mode": "regex"}
override := map[string]any{
"operations": []any{map[string]any{
"mode": "prune_objects", "path": "messages",
"value": map[string]any{
"conditions": []any{condition},
},
}},
}
// Reusing a channel configuration must not reuse compiled patterns across requests.
for _, tt := range []struct {
name string
input string
pattern string
want string
wantError string
}{
{
name: "one condition matches several objects",
input: `{"messages":[{"role":"system"},{"role":"user"},{"role":"developer"}]}`,
pattern: `^(system|developer)$`, want: `{"messages":[{"role":"user"}]}`,
},
{
name: "next request uses updated pattern",
input: `{"messages":[{"role":"system"},{"role":"user"},{"role":"developer"}]}`,
pattern: `^user$`, want: `{"messages":[{"role":"system"},{"role":"developer"}]}`,
},
{
name: "missing keys skip invalid pattern",
input: `{"messages":[{"content":"hello"},{"content":"world"}]}`,
pattern: `[`, want: `{"messages":[{"content":"hello"},{"content":"world"}]}`,
},
{
name: "invalid pattern errors on first present key after missing keys",
input: `{"messages":[{"content":"hello"},{"role":"user"},{"role":"system"}]}`,
pattern: `[`, wantError: "comparison failed for path role: invalid regex pattern",
},
} {
t.Run(tt.name, func(t *testing.T) {
condition["value"] = tt.pattern
out, err := ApplyParamOverride([]byte(tt.input), override, nil)
if tt.wantError != "" {
require.ErrorContains(t, err, tt.wantError)
return
}
require.NoError(t, err)
assert.JSONEq(t, tt.want, string(out))
})
}
}
func TestApplyParamOverrideConditionORDefault(t *testing.T) {
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
override := map[string]any{
+61 -15
View File
@@ -47,9 +47,8 @@ type RerankerInfo struct {
}
type BuildInToolInfo struct {
ToolName string
CallCount int
SearchContextSize string
ToolName string
CallCount int
}
type ResponsesUsageInfo struct {
@@ -91,6 +90,7 @@ type RelayInfo struct {
TokenUnlimited bool
StartTime time.Time
FirstResponseTime time.Time
UpstreamTiming *UpstreamTiming
isFirstResponse bool
//SendLastReasoningResponse bool
IsStream bool
@@ -201,6 +201,27 @@ type RelayInfo struct {
FinalRequestRelayFormat types.RelayFormat
StreamStatus *StreamStatus
// VendorToolUsage reads the upstream's own billed tool counts from a raw
// response body (non-stream) or the raw terminal stream frame. The channel
// adaptor's Init sets it for upstreams whose usage extension names a billed
// count; it stays nil for everyone else. Keys are tool-price keys. A zero
// value suppresses the protocol items counted under that key, so a vendor
// count can supersede web_search_call items billed under a different key.
VendorToolUsage func(raw []byte) map[string]int
// WebSearchBillingKey overrides the tool-price key of hosted web search:
// usage.server_tool_use.web_search_requests and web_search_tool_result
// blocks in the Claude handler, and web_search_call items counted by
// CountBillableToolCall. Set by a channel adaptor whose upstream bills
// search under its own price list; empty keeps the protocol defaults.
WebSearchBillingKey string
// WebFetchBillingKey overrides the tool-price key of
// usage.server_tool_use.web_fetch_requests in the Claude handler, for an
// upstream that bills page fetches under its own price list; empty keeps
// the protocol default web_fetch.
WebFetchBillingKey string
// requestInferredToolKey is the tool-price key RecordRequestInferredToolCall
// billed for the current channel attempt.
requestInferredToolKey string
// PerformanceOutputTokens is captured by settlement and sampled once at
// the request boundary, independently of billing success or failure.
PerformanceOutputTokens int64
@@ -208,6 +229,9 @@ type RelayInfo struct {
// convOptions caches the converter settings snapshot (see ConvOptions).
convOptions *convmeta.Options
// responsesToolState is written by request conversion and read by the
// matching response conversion (see ResponsesToolState).
responsesToolState *convmeta.ResponsesToolState
conversionDiagnostics []types.ConversionDiagnostic
conversionDiagnosticKeys map[conversionDiagnosticKey]struct{}
@@ -317,6 +341,14 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
// Channel identity feeds the converter options snapshot (e.g.
// OpenRouterDialect); drop the cache so a cross-channel retry rebuilds it.
info.convOptions = nil
// The adaptor Init of this attempt sets the vendor billing hooks again.
info.VendorToolUsage = nil
info.WebSearchBillingKey = ""
info.WebFetchBillingKey = ""
if info.requestInferredToolKey != "" && info.ResponsesUsageInfo != nil {
delete(info.ResponsesUsageInfo.BuiltInTools, info.requestInferredToolKey)
}
info.requestInferredToolKey = ""
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelMeta.ChannelSetting.PassThroughBodyEnabled {
info.ReasoningEffort = ""
info.ReasoningConversion = nil
@@ -497,14 +529,6 @@ func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest)
ToolName: toolType,
CallCount: 0,
}
switch toolType {
case dto.BuildInToolWebSearchPreview:
searchContextSize := common.Interface2String(tool["search_context_size"])
if searchContextSize == "" {
searchContextSize = "medium"
}
info.ResponsesUsageInfo.BuiltInTools[toolType].SearchContextSize = searchContextSize
}
}
}
return info
@@ -716,6 +740,9 @@ func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Req
return GenRelayInfoAlphaSearch(c, request), nil
}
return nil, errors.New("request is not a AlphaSearchRequest")
case types.RelayFormatDeepSeekTools:
info = genBaseRelayInfo(c, request)
info.RelayFormat = types.RelayFormatDeepSeekTools
case types.RelayFormatTask:
info = genBaseRelayInfo(c, nil)
info.TaskRelayInfo = &TaskRelayInfo{}
@@ -913,6 +940,19 @@ func (info *RelayInfo) EnsureClaudeConvertInfo() *convmeta.ClaudeConvertInfo {
return info.ClaudeConvertInfo
}
func (info *RelayInfo) ResponsesToolState() *convmeta.ResponsesToolState {
if info == nil {
return nil
}
return info.responsesToolState
}
func (info *RelayInfo) SetResponsesToolState(state *convmeta.ResponsesToolState) {
if info != nil {
info.responsesToolState = state
}
}
func (info *RelayInfo) GetSendResponseCount() int {
if info == nil {
return 0
@@ -936,6 +976,7 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options {
claudeSettings := model_setting.GetClaudeSettings()
geminiSettings := model_setting.GetGeminiSettings()
channelType := info.GetChannelType()
options := &convmeta.Options{
Claude: convmeta.ClaudeOptions{
ThinkingAdapterEnabled: claudeSettings.ThinkingAdapterEnabled,
@@ -948,6 +989,11 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options {
FunctionCallThoughtSignatureEnabled: geminiSettings.FunctionCallThoughtSignatureEnabled,
SupportsImagine: model_setting.IsGeminiModelSupportImagine,
SafetySetting: model_setting.GetGeminiSafetySetting,
// Gemini API v1 has no parametersJsonSchema; v1beta and Vertex AI
// accept it.
SupportsJSONSchema: func(modelName string) bool {
return channelType == constant.ChannelTypeVertexAi || model_setting.GetGeminiVersionSetting(modelName) != "v1"
},
},
OpenRouterDialect: info != nil && info.GetChannelType() == constant.ChannelTypeOpenRouter,
PreserveThinkingSuffix: model_setting.ShouldPreserveThinkingSuffix,
@@ -1120,7 +1166,7 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
var data map[string]any
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error())
common.SysError(common.LogText("RemoveDisabledFields Unmarshal error :%s", err.Error()))
return jsonData, nil
}
@@ -1177,7 +1223,7 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveDisabledFields Marshal error :" + err.Error())
common.SysError(common.LogText("RemoveDisabledFields Marshal error :%s", err.Error()))
return jsonData, nil
}
return jsonDataAfter, nil
@@ -1211,7 +1257,7 @@ func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) {
var data map[string]any
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveGeminiDisabledFields Unmarshal error: " + err.Error())
common.SysError(common.LogText("RemoveGeminiDisabledFields Unmarshal error: %s", err.Error()))
return jsonData, nil
}
@@ -1240,7 +1286,7 @@ func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) {
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveGeminiDisabledFields Marshal error: " + err.Error())
common.SysError(common.LogText("RemoveGeminiDisabledFields Marshal error: %s", err.Error()))
return jsonData, nil
}
return jsonDataAfter, nil
@@ -3,6 +3,7 @@ package common
import (
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/stretchr/testify/assert"
)
@@ -23,3 +24,35 @@ func TestRelayInfoConvOptionsUsesNormalizedGeminiSafetySettings(t *testing.T) {
assert.Equal(t, "OFF", options.Gemini.SafetySetting("HARM_CATEGORY_HATE_SPEECH"))
assert.Equal(t, "BLOCK_ONLY_HIGH", options.Gemini.SafetySetting("HARM_CATEGORY_DANGEROUS_CONTENT"))
}
// TestRelayInfoConvOptionsGeminiJSONSchemaSupport covers which upstreams get
// parametersJsonSchema and responseJsonSchema: every channel except one whose
// Gemini API version for the model is v1, and Vertex AI regardless of it.
func TestRelayInfoConvOptionsGeminiJSONSchemaSupport(t *testing.T) {
settings := model_setting.GetGeminiSettings()
original := settings.VersionSettings
t.Cleanup(func() {
settings.VersionSettings = original
})
settings.VersionSettings = map[string]string{"default": "v1beta", "gemini-v1-model": "v1"}
tests := []struct {
name string
channelType int
model string
want bool
}{
{name: "Gemini on the default version", channelType: constant.ChannelTypeGemini, model: "gemini-3.7-flash", want: true},
{name: "Gemini on v1", channelType: constant.ChannelTypeGemini, model: "gemini-v1-model", want: false},
{name: "Vertex AI", channelType: constant.ChannelTypeVertexAi, model: "gemini-v1-model", want: true},
{name: "other channel on v1", channelType: constant.ChannelTypeAdvancedCustom, model: "gemini-v1-model", want: false},
{name: "other channel on the default version", channelType: constant.ChannelTypeAdvancedCustom, model: "gemini-3.7-flash", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &RelayInfo{ChannelMeta: &ChannelMeta{ChannelType: tt.channelType}}
assert.Equal(t, tt.want, info.ConvOptions().Gemini.SupportsJSONSchemaModel(tt.model))
})
}
}
+15
View File
@@ -68,7 +68,9 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
meta.SetReasoningEffort("high")
meta.IncrSendResponseCount()
meta.AppendRequestConversion(types.RelayFormatClaude)
meta.SetResponsesToolState(&convmeta.ResponsesToolState{})
})
assert.Nil(t, meta.ResponsesToolState())
firstState := meta.EnsureClaudeConvertInfo()
secondState := meta.EnsureClaudeConvertInfo()
@@ -89,6 +91,19 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
assert.NotNil(t, firstOptions.PreserveEffortTail)
}
func TestRelayInfoResponsesToolStateRoundTrip(t *testing.T) {
info := &RelayInfo{}
state := &convmeta.ResponsesToolState{CustomToolNames: map[string]struct{}{"exec": {}}}
info.SetResponsesToolState(state)
assert.Same(t, state, info.ResponsesToolState())
assert.True(t, convmeta.ResponsesToolStateOf(info).IsCustomTool("exec"))
info.SetResponsesToolState(nil)
assert.Nil(t, info.ResponsesToolState())
assert.False(t, convmeta.ResponsesToolStateOf(info).IsCustomTool("exec"))
}
func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
+29 -17
View File
@@ -4,17 +4,40 @@ import "strings"
// ResponseModel records upstream declarations before response conversion. It is
// diagnostic only: it must never change routing, pricing, or downstream output.
// Only the three names are stored; whether they disagree is computed on demand
// so every consumer applies the current comparison rule to old rows as well.
type ResponseModel struct {
RequestedModel string `json:"requested_model"`
UpstreamModel string `json:"upstream_model"`
ReturnedModel string `json:"returned_model"`
Mismatch bool `json:"mismatch"`
}
// matches reports whether an upstream declaration is compatible with the
// requested or upstream model: equal ignoring case, a dated or variant name
// that extends it, or the same name behind a provider path such as
// "deepseek/deepseek-v4.1-flash".
func (r *ResponseModel) matches(model string) bool {
if r.RequestedModel == "gpt-6-astra" { return strings.TrimSpace(model) == "gpt-6-astra" }
returned := strings.ToLower(model)
for _, expected := range []string{r.RequestedModel, r.UpstreamModel} {
expected = strings.ToLower(expected)
if expected != "" && (strings.HasPrefix(returned, expected) || strings.HasSuffix(returned, expected)) {
return true
}
}
return false
}
// Mismatch reports whether the retained upstream declaration disagrees with
// both the requested and upstream models.
func (r *ResponseModel) Mismatch() bool {
return r != nil && r.ReturnedModel != "" && !r.matches(r.ReturnedModel)
}
// ObserveResponseModel retains the first differing model for inspection, with
// mismatches taking priority over prefix or case-only differences. A later
// matching or empty event cannot erase it. Only observe upstream declarations,
// never models synthesized by a response converter.
// mismatches taking priority over provider-path, prefix, or case-only
// differences. A later matching or empty event cannot erase it. Only observe
// upstream declarations, never models synthesized by a response converter.
func (info *RelayInfo) ObserveResponseModel(model string) {
if info == nil || strings.TrimSpace(model) == "" || (info.OriginModelName == "gpt-6-astra" && info.AstraDetectionDisabled) {
return
@@ -26,23 +49,12 @@ func (info *RelayInfo) ObserveResponseModel(model string) {
}
}
observation := info.ResponseModel
if observation.Mismatch {
if observation.Mismatch() {
return
}
mismatch := true
for _, expected := range []string{observation.RequestedModel, observation.UpstreamModel} {
if expected != "" && (strings.HasPrefix(model, expected) || strings.EqualFold(model, expected)) {
mismatch = false
break
}
}
if observation.RequestedModel == "gpt-6-astra" {
mismatch = strings.TrimSpace(model) != "gpt-6-astra"
}
if !mismatch && observation.ReturnedModel != "" &&
if observation.matches(model) && observation.ReturnedModel != "" &&
observation.ReturnedModel != observation.RequestedModel && observation.ReturnedModel != observation.UpstreamModel {
return
}
observation.ReturnedModel = model
observation.Mismatch = mismatch
}
+4
View File
@@ -32,6 +32,7 @@ type ResponseGuardUsage struct {
}
type ResponseGuardAttempt struct {
Timing *UpstreamTiming `json:"-"`
OnEvent func([]byte, bool) `json:"-"`
OnClose func() `json:"-"`
closeOnce sync.Once
@@ -141,6 +142,9 @@ func (g *ResponseModelGuard) APIError() *types.NewAPIError {
// Only read the provider's declaration and numeric usage, never generated text.
func (a *ResponseGuardAttempt) observe(payload []byte, stream bool) error {
if stream {
a.Timing.Mark("first_sse_data")
}
if a.OnEvent != nil {
a.OnEvent(payload, stream)
}
+75 -50
View File
@@ -1,6 +1,7 @@
package common
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"fmt"
@@ -11,39 +12,44 @@ import (
"github.com/QuantumNous/new-api/setting/operation_setting"
)
// GoogleSearchGroundedPromptTool bills Gemini 2.5-and-older Google Search
// grounding, charged once per grounded prompt.
const GoogleSearchGroundedPromptTool = "google_search_grounded_prompt"
// reservedBillableToolNames are hosted-tool keys without a built-in price
// (Anthropic server tools reported in usage.server_tool_use). They and every
// tool name the built-in price seed prices
// (operation_setting.IsBuiltInToolPriceKey) bill hosted tools; a client
// function or tool_use with the same name is never counted under them.
var reservedBillableToolNames = map[string]struct{}{
dto.BuildInToolWebSearchPreview: {},
dto.BuildInToolWebSearch: {},
dto.BuildInToolFileSearch: {},
dto.BuildInToolGoogleSearch: {},
dto.BuildInToolImageGeneration: {},
"web_fetch": {},
"code_execution": {},
"tool_search": {},
}
// MaxBillableToolCallCount bounds every tool count at settlement, whatever its
// source, before it becomes a surcharge multiplier (see
// .agents/rules/billing.md).
const MaxBillableToolCallCount = 10_000
// CountBillableToolCall is the single entry point for per-call tool billing counts.
// Built-in call types always count; custom function/tool_use names only count when priced.
func (info *RelayInfo) CountBillableToolCall(itemType string, functionName string) {
if info == nil {
return
}
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &ResponsesUsageInfo{
BuiltInTools: make(map[string]*BuildInToolInfo),
}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*BuildInToolInfo)
}
info.ensureBuiltInTools()
switch itemType {
case dto.BuildInCallWebSearchCall:
info.incrementBillableToolCall(resolveWebSearchToolName(info.ResponsesUsageInfo.BuiltInTools))
info.incrementBillableToolCall(cmp.Or(info.WebSearchBillingKey, resolveWebSearchToolName(info.ResponsesUsageInfo.BuiltInTools)))
case dto.BuildInCallFileSearchCall:
info.incrementBillableToolCall(dto.BuildInToolFileSearch)
case dto.BuildInCallFunctionCall, dto.BuildInCallToolUse:
if functionName == "" {
return
}
if _, reserved := reservedBillableToolNames[functionName]; reserved {
if _, reserved := reservedBillableToolNames[functionName]; reserved || operation_setting.IsBuiltInToolPriceKey(functionName) {
return
}
if operation_setting.GetToolPriceForModel(functionName, info.GetBillingModelName()) <= 0 {
@@ -53,6 +59,59 @@ func (info *RelayInfo) CountBillableToolCall(itemType string, functionName strin
}
}
// SetBillableToolCount records an upstream-reported count for one tool-price
// key, replacing whatever per-item counting produced for the same key. Zero
// is a valid value: a vendor billing field that reports zero suppresses the
// protocol items counted under that key. Negative counts are ignored.
func (info *RelayInfo) SetBillableToolCount(name string, count int) {
if info == nil || name == "" {
return
}
if count < 0 {
common.SysError(common.LogText("billable tool count ignored: tool=%s count=%d", name, count))
return
}
info.ensureBuiltInTools()
if existing, ok := info.ResponsesUsageInfo.BuiltInTools[name]; ok && existing != nil {
existing.CallCount = count
return
}
info.ResponsesUsageInfo.BuiltInTools[name] = &BuildInToolInfo{ToolName: name, CallCount: count}
}
// ApplyVendorToolUsage writes the vendor-billed counts of one upstream
// response into BuiltInTools. Protocol items counted earlier for the same key
// are replaced; keys the vendor does not mention are kept.
func (info *RelayInfo) ApplyVendorToolUsage(raw []byte) {
if info == nil || info.VendorToolUsage == nil || len(raw) == 0 {
return
}
for name, count := range info.VendorToolUsage(raw) {
info.SetBillableToolCount(name, count)
}
}
// RecordRequestInferredToolCall bills one call of key that the request itself
// proves: a forced hosted search on an upstream whose response reports no
// search usage. The record belongs to the current channel attempt;
// InitChannelMeta drops it when the request moves to another channel.
func (info *RelayInfo) RecordRequestInferredToolCall(key string) {
if info == nil || key == "" {
return
}
info.SetBillableToolCount(key, 1)
info.requestInferredToolKey = key
}
func (info *RelayInfo) ensureBuiltInTools() {
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &ResponsesUsageInfo{}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*BuildInToolInfo)
}
}
func resolveWebSearchToolName(tools map[string]*BuildInToolInfo) string {
if _, ok := tools[dto.BuildInToolWebSearchPreview]; ok {
return dto.BuildInToolWebSearchPreview
@@ -125,15 +184,6 @@ func (c *ImageGenerationCallCounter) Observe(item *dto.ResponsesOutput, outputIn
c.count++
}
// Reset clears pending observations (used when a terminal response fails).
func (c *ImageGenerationCallCounter) Reset() {
if c == nil {
return
}
c.seen = nil
c.count = 0
}
// Count returns the deduplicated completed image output count before commit capping.
func (c *ImageGenerationCallCounter) Count() int {
if c == nil {
@@ -148,14 +198,7 @@ func (c *ImageGenerationCallCounter) Commit(info *RelayInfo) {
if info == nil {
return
}
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &ResponsesUsageInfo{
BuiltInTools: make(map[string]*BuildInToolInfo),
}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*BuildInToolInfo)
}
info.ensureBuiltInTools()
count := 0
if c != nil {
@@ -174,21 +217,3 @@ func (c *ImageGenerationCallCounter) Commit(info *RelayInfo) {
CallCount: count,
}
}
// IsNonBillableResponsesStatus reports terminal response statuses that must not
// bill pending image_generation observations.
func IsNonBillableResponsesStatus(status []byte) bool {
if len(status) == 0 {
return false
}
var s string
if err := common.Unmarshal(status, &s); err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(s)) {
case "failed", "cancelled", "canceled", "incomplete":
return true
default:
return false
}
}
+117 -35
View File
@@ -1,11 +1,13 @@
package common
import (
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -35,6 +37,25 @@ func TestCountBillableToolCallWebSearchDefaultsToPreview(t *testing.T) {
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount)
}
// A channel whose upstream bills search itself keys web_search_call items to
// its own price, so a mapped claude-* or gpt-* name never bills web_search.
func TestCountBillableToolCallWebSearchUsesChannelBillingKey(t *testing.T) {
info := &RelayInfo{
OriginModelName: "gpt-5.1-codex",
WebSearchBillingKey: "deepseek_search",
ResponsesUsageInfo: &ResponsesUsageInfo{
BuiltInTools: map[string]*BuildInToolInfo{
dto.BuildInToolWebSearch: {ToolName: dto.BuildInToolWebSearch, CallCount: 0},
},
},
}
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "deepseek_search")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["deepseek_search"].CallCount)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearch].CallCount)
}
func TestCountBillableToolCallFunctionCallRequiresPrice(t *testing.T) {
operation_setting.SetToolPriceForTest("my_priced_fn", 5.0)
t.Cleanup(func() {
@@ -50,19 +71,105 @@ func TestCountBillableToolCallFunctionCallRequiresPrice(t *testing.T) {
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, "unpriced_fn")
}
// Hosted-tool price keys bill hosted tools only: a client function or
// tool_use with the same name is never counted, even when the operator prices
// that name. The reserved set is the built-in price seed plus the hosted
// tools without a built-in price; an operator price alone reserves nothing.
func TestCountBillableToolCallFunctionCallSkipsReservedNames(t *testing.T) {
info := &RelayInfo{OriginModelName: "gpt-5.1"}
wantBilled := map[string]bool{
dto.BuildInToolWebSearchPreview: false,
dto.BuildInToolFileSearch: false,
dto.BuildInToolGoogleSearch: false,
dto.BuildInToolImageGeneration: false,
"bing_web_search": false, // built-in vendor price
GoogleSearchGroundedPromptTool: false, // built-in only for gemini-2.5* and older
"web_fetch": false, // Anthropic server tool, no built-in price
"lookup_order": true, // operator-priced client function
}
for name := range wantBilled {
operation_setting.SetToolPriceForTest(name, 7)
}
t.Cleanup(func() {
for name := range wantBilled {
operation_setting.DeleteToolPriceForTest(name)
}
})
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolWebSearchPreview)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolFileSearch)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolGoogleSearch)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolImageGeneration)
for name, billed := range wantBilled {
info := &RelayInfo{OriginModelName: "gpt-5.1"}
info.CountBillableToolCall(dto.BuildInCallFunctionCall, name)
info.CountBillableToolCall(dto.BuildInCallToolUse, name)
if billed {
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, name)
assert.Equal(t, 2, info.ResponsesUsageInfo.BuiltInTools[name].CallCount, name)
continue
}
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, name)
}
}
if info.ResponsesUsageInfo != nil {
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolWebSearchPreview)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolFileSearch)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolGoogleSearch)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolImageGeneration)
// Upstream-reported counts replace per-item counts for the same key (an
// explicit zero silences them) and ignore negatives; a count the request
// itself proved belongs to one channel attempt, and a retry on another
// channel drops it together with the vendor billing hooks.
func TestBillableToolCountSources(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
record func(c *gin.Context, info *RelayInfo)
want map[string]int
wantVendorHook bool
wantBillingKey string
}{
{
name: "negative upstream count is ignored",
record: func(_ *gin.Context, info *RelayInfo) {
info.SetBillableToolCount(dto.BuildInToolWebSearch, 3)
info.SetBillableToolCount(dto.BuildInToolWebSearch, -5)
},
want: map[string]int{dto.BuildInToolWebSearch: 3},
},
{
name: "vendor zero replaces protocol items",
record: func(_ *gin.Context, info *RelayInfo) {
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
info.VendorToolUsage = func([]byte) map[string]int {
return map[string]int{"bing_web_search": 5, dto.BuildInToolWebSearchPreview: 0}
}
info.ApplyVendorToolUsage([]byte(`{}`))
},
want: map[string]int{"bing_web_search": 5, dto.BuildInToolWebSearchPreview: 0},
wantVendorHook: true,
},
{
name: "channel retry drops the request-inferred call and vendor hooks",
record: func(c *gin.Context, info *RelayInfo) {
info.SetBillableToolCount(dto.BuildInToolWebSearch, 2)
info.RecordRequestInferredToolCall("search_strategy_turbo")
info.VendorToolUsage = func([]byte) map[string]int { return nil }
info.WebSearchBillingKey = "search_strategy_agent"
info.WebFetchBillingKey = "deepseek_fetch"
info.InitChannelMeta(c)
},
want: map[string]int{dto.BuildInToolWebSearch: 2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
info := &RelayInfo{OriginModelName: "gpt-5.1"}
tt.record(c, info)
counts := map[string]int{}
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
counts[name] = tool.CallCount
}
assert.Equal(t, tt.want, counts)
assert.Equal(t, tt.wantVendorHook, info.VendorToolUsage != nil)
assert.Equal(t, tt.wantBillingKey, info.WebSearchBillingKey)
assert.Empty(t, info.WebFetchBillingKey)
})
}
}
@@ -248,20 +355,6 @@ func TestImageGenerationCallCounterCompletedOutputs(t *testing.T) {
},
wantCount: 1,
},
{
name: "output_item.done plus incomplete equals zero",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "completed",
Result: "base64-a",
}, &idx)
c.Reset()
},
wantCount: 0,
},
{
name: "partial event equals zero",
observe: func(c *ImageGenerationCallCounter) {
@@ -335,14 +428,3 @@ func TestImageGenerationCallCounterCommitDoesNotBillDeclarationsAlone(t *testing
(&ImageGenerationCallCounter{}).Commit(info)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestIsNonBillableResponsesStatus(t *testing.T) {
t.Parallel()
assert.True(t, IsNonBillableResponsesStatus([]byte(`"failed"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"incomplete"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"cancelled"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"canceled"`)))
assert.False(t, IsNonBillableResponsesStatus([]byte(`"completed"`)))
assert.False(t, IsNonBillableResponsesStatus(nil))
}
+113
View File
@@ -0,0 +1,113 @@
package common
import (
"crypto/tls"
"maps"
"net/http/httptrace"
"sync"
"time"
)
const UpstreamTimingContextKey = "responses_upstream_timing"
// UpstreamTiming contains only transport metadata and request-relative times.
// HTTP callbacks can run concurrently with streaming and log checkpoints.
type UpstreamTiming struct {
mu sync.Mutex
start time.Time
events map[string]float64
attempt int
channel int
bodyBytes int64
reused bool
gotConn bool
getConnCount int
writeCount int
writeFailed bool
protocol string
status int
}
func NewUpstreamTiming(start time.Time, attempt, channel int, bodyBytes int64) *UpstreamTiming {
if start.IsZero() {
start = time.Now()
}
t := &UpstreamTiming{start: start, events: make(map[string]float64), attempt: attempt, channel: channel, bodyBytes: bodyBytes}
t.Mark("upstream_start")
return t
}
func (t *UpstreamTiming) Mark(event string) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if _, exists := t.events[event]; !exists {
t.events[event] = float64(time.Since(t.start).Microseconds()) / 1000
}
}
func (t *UpstreamTiming) ClientTrace() *httptrace.ClientTrace {
return &httptrace.ClientTrace{
GetConn: func(string) {
t.Mark("get_connection")
t.mu.Lock()
t.getConnCount++
t.mu.Unlock()
},
DNSStart: func(httptrace.DNSStartInfo) { t.Mark("dns_start") },
DNSDone: func(httptrace.DNSDoneInfo) { t.Mark("dns_done") },
ConnectStart: func(string, string) { t.Mark("tcp_start") },
ConnectDone: func(string, string, error) { t.Mark("tcp_done") },
TLSHandshakeStart: func() { t.Mark("tls_start") },
TLSHandshakeDone: func(tls.ConnectionState, error) { t.Mark("tls_done") },
GotConn: func(info httptrace.GotConnInfo) {
t.Mark("connection_ready")
t.mu.Lock()
t.reused, t.gotConn = info.Reused, true
t.mu.Unlock()
},
WroteRequest: func(info httptrace.WroteRequestInfo) {
t.mu.Lock()
defer t.mu.Unlock()
t.writeCount++
t.writeFailed = t.writeFailed || info.Err != nil
if info.Err == nil {
t.events["request_sent"] = float64(time.Since(t.start).Microseconds()) / 1000
}
},
GotFirstResponseByte: func() { t.Mark("response_first_byte") },
}
}
func (t *UpstreamTiming) Response(protocol string, status int) {
if t == nil {
return
}
t.Mark("response_headers")
t.mu.Lock()
defer t.mu.Unlock()
t.protocol, t.status = protocol, status
}
func (t *UpstreamTiming) Snapshot() map[string]any {
if t == nil {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
events := maps.Clone(t.events)
result := map[string]any{
"events_ms": events, "attempt": t.attempt, "channel_id": t.channel,
"request_body_bytes": t.bodyBytes, "get_connection_count": t.getConnCount,
"write_count": t.writeCount, "write_failed": t.writeFailed,
}
if t.gotConn {
result["connection_reused"] = t.reused
}
if t.protocol != "" {
result["protocol"], result["status"] = t.protocol, t.status
}
return result
}