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:
Calcium-Ion
2026-09-01 21:53:35 +08:00
committed by GitHub
parent b7017c251b
commit 0ed497f066
149 changed files with 14729 additions and 3044 deletions
+71
View File
@@ -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
}
+76 -5
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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) {
+1 -1
View File
@@ -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)