mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41:21 +00:00
feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)
* feat(relaykit): preserve hosted tools across conversions - add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies - bridge citations, grounding metadata, and hosted-tool stream lifecycles - document the public conversion behavior and channel policy controls * refactor(relaykit): normalize reasoning and thinking intent - centralize provider-neutral reasoning intent, effort, and budget mappings - parse model suffixes at the host entry boundary while preserving provider-owned tails - keep adaptive Claude thinking and explicit zero-token compatibility consistent * fix(billing): preserve authoritative usage across relay hops - carry native BillingUsage sidecars through direct and streamed protocol bridges - merge partial and terminal usage monotonically with safe fallback settlement - retain cache metadata, penultimate usage, and per-call Gemini tool surcharges * feat(relay): bridge Responses with Claude and Gemini protocols - add direct request, response, and stream converters across supported relay formats - expose Claude count_tokens and Chat-to-Responses compatibility endpoints - carry conversion diagnostics through the host while retaining the curated public goldens * fix(relay): wire relaykit conversions into host channels - connect handlers, adaptors, and channel settings to the standalone conversion layer - keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned - ignore local audit artifacts and retain focused public regression coverage
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxConversionDiagnostics = 32
|
||||
|
||||
type conversionDiagnosticKey struct {
|
||||
code string
|
||||
path string
|
||||
severity types.ConversionDiagnosticSeverity
|
||||
from types.RelayFormat
|
||||
to types.RelayFormat
|
||||
}
|
||||
|
||||
// RecordConversionDiagnostics retains conversion losses for the consume log
|
||||
// and emits one request-correlated warning per distinct diagnostic. The cap
|
||||
// prevents malformed streams from growing request state without bound.
|
||||
func (info *RelayInfo) RecordConversionDiagnostics(ctx context.Context, diagnostics []types.ConversionDiagnostic) {
|
||||
if info == nil || len(diagnostics) == 0 {
|
||||
return
|
||||
}
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx == nil {
|
||||
ctx = nil
|
||||
}
|
||||
if info.conversionDiagnosticKeys == nil {
|
||||
info.conversionDiagnosticKeys = make(map[conversionDiagnosticKey]struct{})
|
||||
}
|
||||
for _, diagnostic := range diagnostics {
|
||||
key := conversionDiagnosticKey{
|
||||
code: diagnostic.Code,
|
||||
path: diagnostic.Path,
|
||||
severity: diagnostic.Severity,
|
||||
from: diagnostic.From,
|
||||
to: diagnostic.To,
|
||||
}
|
||||
if _, exists := info.conversionDiagnosticKeys[key]; exists {
|
||||
continue
|
||||
}
|
||||
if len(info.conversionDiagnostics) >= maxConversionDiagnostics {
|
||||
if !info.conversionDiagnosticsTruncated {
|
||||
info.conversionDiagnosticsTruncated = true
|
||||
logger.LogWarn(ctx, fmt.Sprintf("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
|
||||
}
|
||||
continue
|
||||
}
|
||||
info.conversionDiagnosticKeys[key] = struct{}{}
|
||||
info.conversionDiagnostics = append(info.conversionDiagnostics, diagnostic)
|
||||
logger.LogWarn(ctx, fmt.Sprintf(
|
||||
"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,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (info *RelayInfo) ConversionDiagnostics() []types.ConversionDiagnostic {
|
||||
if info == nil || len(info.conversionDiagnostics) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]types.ConversionDiagnostic(nil), info.conversionDiagnostics...)
|
||||
}
|
||||
|
||||
func (info *RelayInfo) ConversionDiagnosticsTruncated() bool {
|
||||
return info != nil && info.conversionDiagnosticsTruncated
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -224,22 +225,73 @@ func syncReasoningEffortAfterParamOverride(info *RelayInfo, before, after []byte
|
||||
}
|
||||
|
||||
func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (string, bool) {
|
||||
var paths []string
|
||||
switch format {
|
||||
case types.RelayFormatOpenAI:
|
||||
paths = []string{"reasoning_effort", "reasoning.effort"}
|
||||
if effort, exists := firstStringValue(data, "reasoning_effort"); exists && effort != "" {
|
||||
return effort, true
|
||||
}
|
||||
if enabled := gjson.GetBytes(data, "reasoning.enabled"); enabled.Exists() {
|
||||
if enabled.Type != gjson.True && enabled.Type != gjson.False {
|
||||
return "", true
|
||||
}
|
||||
if !enabled.Bool() {
|
||||
return string(kitreasoning.EffortNone), true
|
||||
}
|
||||
if effort, exists := firstStringValue(data, "reasoning.effort"); exists && effort != "" {
|
||||
return effort, true
|
||||
}
|
||||
if budget := gjson.GetBytes(data, "reasoning.max_tokens"); budget.Exists() {
|
||||
return reasoningEffortFromBudgetValue(budget)
|
||||
}
|
||||
return string(kitreasoning.EffortHigh), true
|
||||
}
|
||||
if effort, exists := firstStringValue(data, "reasoning.effort"); exists && effort != "" {
|
||||
return effort, true
|
||||
}
|
||||
if budget := gjson.GetBytes(data, "reasoning.max_tokens"); budget.Exists() {
|
||||
return reasoningEffortFromBudgetValue(budget)
|
||||
}
|
||||
return "", false
|
||||
case types.RelayFormatOpenAIResponses:
|
||||
paths = []string{"reasoning.effort"}
|
||||
return firstStringValue(data, "reasoning.effort")
|
||||
case types.RelayFormatClaude:
|
||||
paths = []string{"output_config.effort"}
|
||||
if effort, exists := firstStringValue(data, "output_config.effort"); exists && effort != "" {
|
||||
return effort, true
|
||||
}
|
||||
thinkingType, hasThinkingType := firstStringValue(data, "thinking.type")
|
||||
if thinkingType == "disabled" {
|
||||
return string(kitreasoning.EffortNone), true
|
||||
}
|
||||
if budget := gjson.GetBytes(data, "thinking.budget_tokens"); budget.Exists() {
|
||||
return reasoningEffortFromBudgetValue(budget)
|
||||
}
|
||||
if thinkingType == "enabled" || thinkingType == "adaptive" {
|
||||
return string(kitreasoning.EffortHigh), true
|
||||
}
|
||||
return "", hasThinkingType
|
||||
case types.RelayFormatGemini:
|
||||
paths = []string{
|
||||
level, hasLevel := firstStringValue(data,
|
||||
"generationConfig.thinkingConfig.thinkingLevel",
|
||||
"generation_config.thinking_config.thinking_level",
|
||||
)
|
||||
if level != "" {
|
||||
return level, true
|
||||
}
|
||||
for _, path := range []string{
|
||||
"generationConfig.thinkingConfig.thinkingBudget",
|
||||
"generation_config.thinking_config.thinking_budget",
|
||||
} {
|
||||
if budget := gjson.GetBytes(data, path); budget.Exists() {
|
||||
return reasoningEffortFromBudgetValue(budget)
|
||||
}
|
||||
}
|
||||
return "", hasLevel
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func firstStringValue(data []byte, paths ...string) (string, bool) {
|
||||
for _, path := range paths {
|
||||
value := gjson.GetBytes(data, path)
|
||||
if !value.Exists() {
|
||||
@@ -253,6 +305,25 @@ func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (stri
|
||||
return "", false
|
||||
}
|
||||
|
||||
func reasoningEffortFromBudgetValue(value gjson.Result) (string, bool) {
|
||||
if value.Type != gjson.Number {
|
||||
return "", true
|
||||
}
|
||||
budget := value.Float()
|
||||
switch {
|
||||
case budget == 0:
|
||||
return string(kitreasoning.EffortNone), true
|
||||
case budget < 0:
|
||||
return string(kitreasoning.EffortHigh), true
|
||||
case budget <= 1024:
|
||||
return string(kitreasoning.EffortLow), true
|
||||
case budget <= 8192:
|
||||
return string(kitreasoning.EffortMedium), true
|
||||
default:
|
||||
return string(kitreasoning.EffortHigh), true
|
||||
}
|
||||
}
|
||||
|
||||
func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
|
||||
if common.DebugEnabled {
|
||||
return true
|
||||
|
||||
+68
-20
@@ -14,6 +14,7 @@ import (
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
hosttypes "github.com/QuantumNous/new-api/types"
|
||||
@@ -98,25 +99,34 @@ type RelayInfo struct {
|
||||
UsePrice bool
|
||||
RelayMode int
|
||||
OriginModelName string
|
||||
RequestURLPath string
|
||||
RequestHeaders map[string]string
|
||||
ShouldIncludeUsage bool
|
||||
DisablePing bool // 是否禁止向下游发送自定义 Ping
|
||||
ClientWs *websocket.Conn
|
||||
TargetWs *websocket.Conn
|
||||
InputAudioFormat string
|
||||
OutputAudioFormat string
|
||||
RealtimeTools []dto.RealTimeTool
|
||||
IsFirstRequest bool
|
||||
AudioUsage bool
|
||||
ReasoningEffort string
|
||||
UserSetting dto.UserSetting
|
||||
UserEmail string
|
||||
UserQuota int
|
||||
RelayFormat types.RelayFormat
|
||||
SendResponseCount int
|
||||
ReceivedResponseCount int
|
||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||
|
||||
// BillingModelName is the pricing identity for this request. It is kept
|
||||
// separate from OriginModelName and UpstreamModelName so virtual pricing
|
||||
// aliases never participate in channel selection or upstream routing.
|
||||
BillingModelName string
|
||||
|
||||
RequestURLPath string
|
||||
RequestHeaders map[string]string
|
||||
ShouldIncludeUsage bool
|
||||
DisablePing bool // 是否禁止向下游发送自定义 Ping
|
||||
ClientWs *websocket.Conn
|
||||
TargetWs *websocket.Conn
|
||||
InputAudioFormat string
|
||||
OutputAudioFormat string
|
||||
RealtimeTools []dto.RealTimeTool
|
||||
IsFirstRequest bool
|
||||
AudioUsage bool
|
||||
ReasoningEffort string
|
||||
// ReasoningConversion is the suffix-derived reasoning intent attached
|
||||
// after model mapping. Converters read it via ReasoningState().
|
||||
ReasoningConversion *dto.ReasoningConversionState
|
||||
UserSetting dto.UserSetting
|
||||
UserEmail string
|
||||
UserQuota int
|
||||
RelayFormat types.RelayFormat
|
||||
SendResponseCount int
|
||||
ReceivedResponseCount int
|
||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
|
||||
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
|
||||
// 必须在提交前锁定全额。
|
||||
@@ -176,6 +186,10 @@ type RelayInfo struct {
|
||||
// convOptions caches the converter settings snapshot (see ConvOptions).
|
||||
convOptions *convmeta.Options
|
||||
|
||||
conversionDiagnostics []types.ConversionDiagnostic
|
||||
conversionDiagnosticKeys map[conversionDiagnosticKey]struct{}
|
||||
conversionDiagnosticsTruncated bool
|
||||
|
||||
ThinkingContentInfo
|
||||
TokenCountMeta
|
||||
*ClaudeConvertInfo
|
||||
@@ -186,6 +200,9 @@ type RelayInfo struct {
|
||||
}
|
||||
|
||||
func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
|
||||
info.FinalRequestRelayFormat = ""
|
||||
info.RequestConversionChain = nil
|
||||
info.InitRequestConversionChain()
|
||||
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
|
||||
paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
|
||||
headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride)
|
||||
@@ -236,8 +253,10 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
|
||||
info.convOptions = nil
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelMeta.ChannelSetting.PassThroughBodyEnabled {
|
||||
info.ReasoningEffort = ""
|
||||
info.ReasoningConversion = nil
|
||||
} else {
|
||||
info.ReasoningEffort = reasoningEffortFromRequest(info.Request)
|
||||
info.ReasoningConversion = nil
|
||||
}
|
||||
|
||||
// reset some fields based on channel meta
|
||||
@@ -261,6 +280,9 @@ func (info *RelayInfo) ToString() string {
|
||||
fmt.Fprintf(b, "IsPlayground: %t, ", info.IsPlayground)
|
||||
fmt.Fprintf(b, "RequestURLPath: %q, ", info.RequestURLPath)
|
||||
fmt.Fprintf(b, "OriginModelName: %q, ", info.OriginModelName)
|
||||
if info.BillingModelName != "" && info.BillingModelName != info.OriginModelName {
|
||||
fmt.Fprintf(b, "BillingModelName: %q, ", info.BillingModelName)
|
||||
}
|
||||
fmt.Fprintf(b, "EstimatePromptTokens: %d, ", info.estimatePromptTokens)
|
||||
fmt.Fprintf(b, "ShouldIncludeUsage: %t, ", info.ShouldIncludeUsage)
|
||||
fmt.Fprintf(b, "DisablePing: %t, ", info.DisablePing)
|
||||
@@ -464,7 +486,10 @@ func reasoningEffortFromRequest(request dto.Request) string {
|
||||
}
|
||||
case *dto.GeminiChatRequest:
|
||||
if req != nil && req.GenerationConfig.ThinkingConfig != nil {
|
||||
effort = req.GenerationConfig.ThinkingConfig.ThinkingLevel
|
||||
intent, err := kitreasoning.FromGemini(req)
|
||||
if err == nil {
|
||||
effort = string(kitreasoning.EffectiveEffort(intent))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(effort)
|
||||
@@ -739,6 +764,18 @@ func (info *RelayInfo) GetOriginModelName() string {
|
||||
return info.OriginModelName
|
||||
}
|
||||
|
||||
// GetBillingModelName returns the effective pricing identity without changing
|
||||
// either the client-visible model or the model sent to the selected channel.
|
||||
func (info *RelayInfo) GetBillingModelName() string {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
if info.BillingModelName != "" {
|
||||
return info.BillingModelName
|
||||
}
|
||||
return info.OriginModelName
|
||||
}
|
||||
|
||||
func (info *RelayInfo) GetUpstreamModelName() string {
|
||||
if info == nil || info.ChannelMeta == nil {
|
||||
return ""
|
||||
@@ -780,6 +817,13 @@ func (info *RelayInfo) SetReasoningEffort(effort string) {
|
||||
info.ReasoningEffort = strings.TrimSpace(effort)
|
||||
}
|
||||
|
||||
func (info *RelayInfo) ReasoningState() *dto.ReasoningConversionState {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return info.ReasoningConversion
|
||||
}
|
||||
|
||||
func (info *RelayInfo) EnsureClaudeConvertInfo() *convmeta.ClaudeConvertInfo {
|
||||
if info == nil {
|
||||
return &convmeta.ClaudeConvertInfo{
|
||||
@@ -832,8 +876,12 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options {
|
||||
},
|
||||
OpenRouterDialect: info != nil && info.GetChannelType() == constant.ChannelTypeOpenRouter,
|
||||
PreserveThinkingSuffix: model_setting.ShouldPreserveThinkingSuffix,
|
||||
PreserveEffortTail: model_setting.ShouldPreserveEffortTail,
|
||||
}
|
||||
if info != nil {
|
||||
if info.ChannelMeta != nil {
|
||||
options.ToolLossPolicy = types.ConversionLossPolicy(info.ChannelOtherSettings.ToolLossPolicy)
|
||||
}
|
||||
info.convOptions = options
|
||||
}
|
||||
return options
|
||||
|
||||
@@ -56,6 +56,7 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
|
||||
assert.Zero(t, meta.GetChannelType())
|
||||
assert.False(t, meta.GetIsStream())
|
||||
assert.Empty(t, meta.GetReasoningEffort())
|
||||
assert.Nil(t, meta.ReasoningState())
|
||||
assert.Zero(t, meta.GetEstimatePromptTokens())
|
||||
assert.Zero(t, meta.GetSendResponseCount())
|
||||
|
||||
@@ -81,6 +82,7 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
|
||||
assert.NotNil(t, firstOptions.Gemini.SupportsImagine)
|
||||
assert.NotNil(t, firstOptions.Gemini.SafetySetting)
|
||||
assert.NotNil(t, firstOptions.PreserveThinkingSuffix)
|
||||
assert.NotNil(t, firstOptions.PreserveEffortTail)
|
||||
}
|
||||
|
||||
func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
|
||||
|
||||
@@ -46,7 +46,7 @@ func (info *RelayInfo) CountBillableToolCall(itemType string, functionName strin
|
||||
if _, reserved := reservedBillableToolNames[functionName]; reserved {
|
||||
return
|
||||
}
|
||||
if operation_setting.GetToolPriceForModel(functionName, info.OriginModelName) <= 0 {
|
||||
if operation_setting.GetToolPriceForModel(functionName, info.GetBillingModelName()) <= 0 {
|
||||
return
|
||||
}
|
||||
info.incrementBillableToolCall(functionName)
|
||||
|
||||
Reference in New Issue
Block a user