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