Commit 7c044d7c by CaIon

feat(relay): explicit @ model modifiers and canonical billing identity

Model-name post-processing is rebuilt around an explicit trailing
@key:value modifier syntax (thinking/effort/temperature/topp) that
overrides request fields, survives model mapping, and records
conversion diagnostics on the consume log.

- Legacy naked aliases (-thinking, -nothinking, -thinking-<budget>,
  effort tails) now parse only for positively matched families
  (gpt-*/o-series, claude-*, gemini-*, incl. vendor/ namespaces);
  names like qwen-max stay opaque. EffortTailModelIDs remains the
  escape hatch for real in-family IDs such as gpt-5.1-codex-max.
- Billing identity resolves once in ModelPriceHelper via a ladder:
  configured request name first (legacy wildcard entries intact), then
  canonical billing names rebuilt from parsed intent
  (base@effort:E@thinking:S, then base@thinking:S; order, duplicates,
  and budget values are irrelevant; temperature/topp never priced),
  then base. Routing and token limits fall back through
  RoutingMatchModelName; pricing lookups stay wildcard-only.
- Pass-through stays byte-identical: modifiers and aliases are neither
  parsed nor validated there and forward verbatim for the upstream
  (or a chained gateway) to interpret.
- Unknown modifier keys and invalid known-key values are rejected with
  400; models whose real names contain @tag:value are exempted via the
  thinking-suffix blacklist, which now supports re:-prefixed Go regex
  entries.
- Claude reasoning render coerces unsupported combinations (disable,
  adaptive, budgets) with warning diagnostics instead of erroring;
  native-protocol requests without host syntax pass through untouched.

BREAKING(openrouter): drop the host-invented "-thinking" model-name
alias (added in 4f6d16e3) that trimmed any *-thinking model on
OpenRouter channels and injected reasoning.enabled. It matched too
broadly and mangled real model IDs such as kimi-k2-thinking.
Migration: use some-model@thinking:on, or keep the old public name via
a channel model mapping {"some-model-thinking": "some-model@thinking:on"}.
Claude/Gemini family aliases (incl. anthropic/claude-*-thinking) keep
working via the family whitelist.
parent 3a9f41ee
......@@ -259,7 +259,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
}
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return testResult{
context: c,
localErr: err,
......
......@@ -251,7 +251,7 @@ func ListModels(c *gin.Context, modelType int) {
models := service.GetGroupsEnabledModels(ownerGroups)
for _, modelName := range models {
if modelLimitEnable {
matchingName := ratio_setting.FormatMatchingModelName(modelName)
matchingName := ratio_setting.RoutingMatchModelName(modelName)
if !tokenModelLimit[modelName] && !tokenModelLimit[matchingName] {
continue
}
......
......@@ -92,8 +92,7 @@ func Distribute() func(c *gin.Context) {
if !ok {
tokenModelLimit = map[string]bool{}
}
matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-*
if _, ok := tokenModelLimit[matchName]; !ok {
if !tokenModelLimitAllows(tokenModelLimit, modelRequest.Model) {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenModelForbidden, map[string]any{"Model": modelRequest.Model}))
return
}
......@@ -570,6 +569,19 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
return &modelRequest, shouldSelectChannel, nil
}
// tokenModelLimitAllows reports whether a token model-limit map authorizes
// model. Exact name, wildcard-normalized name, and routing-normalized name
// (modifiers and legacy aliases stripped) are all accepted.
func tokenModelLimitAllows(limit map[string]bool, model string) bool {
if limit[model] {
return true
}
if formatted := ratio_setting.FormatMatchingModelName(model); limit[formatted] {
return true
}
return limit[ratio_setting.RoutingMatchModelName(model)]
}
// 修复 #4834: GET /v1/video/generations/:task_id && /v1/video/:task_id 此前不解析 model,
// 当 token 启用「可用模型限制」时,下游 modelLimitEnable 校验会因
// modelRequest.Model 为空而误报 "This token has no access to model"。
......
......@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
......@@ -151,6 +152,32 @@ export const protocols = {openai_responses: {
`, key, key, channelType)
}
func TestTokenModelLimitAllowsLegacyAliasAndModifierVariant(t *testing.T) {
aliasOnly := map[string]bool{"claude-3-7-sonnet-thinking": true}
assert.True(t, tokenModelLimitAllows(aliasOnly, "claude-3-7-sonnet-thinking"))
assert.False(t, tokenModelLimitAllows(aliasOnly, "claude-3-7-sonnet"))
baseOnly := map[string]bool{"claude-3-7-sonnet": true}
assert.True(t, tokenModelLimitAllows(baseOnly, "claude-3-7-sonnet@thinking:on"))
assert.True(t, tokenModelLimitAllows(baseOnly, "claude-3-7-sonnet-thinking"))
wildcard := map[string]bool{"gemini-2.5-flash-thinking-*": true}
assert.True(t, tokenModelLimitAllows(wildcard, "gemini-2.5-flash-thinking-8192"))
}
func TestTokenModelLimitAllowsExemptAtNameByFullName(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
fullOnly := map[string]bool{"opaque@sha256:deadbeef": true}
assert.True(t, tokenModelLimitAllows(fullOnly, "opaque@sha256:deadbeef"))
baseOnly := map[string]bool{"opaque": true}
assert.False(t, tokenModelLimitAllows(baseOnly, "opaque@sha256:deadbeef"))
}
func TestNoAvailableChannelMessageNamesClaimingTaskPlugin(t *testing.T) {
require.NoError(t, i18n.Init())
registry := jsplugin.NewRegistry()
......
......@@ -133,7 +133,7 @@ func GetRandomSatisfiedChannel(
// If no channels found, try to find channels with the normalized model name.
if len(channels) == 0 {
normalizedModel := ratio_setting.FormatMatchingModelName(model)
normalizedModel := ratio_setting.RoutingMatchModelName(model)
channels, _ = filterCandidateIDs(group2model2channels[group][normalizedModel], model, filters)
}
......
......@@ -23,7 +23,7 @@ func IsChannelEnabledForGroupModel(group string, modelName string, channelID int
if isChannelIDInList(group2model2channels[group][modelName], channelID) {
return true
}
normalized := ratio_setting.FormatMatchingModelName(modelName)
normalized := ratio_setting.RoutingMatchModelName(modelName)
if normalized != "" && normalized != modelName {
return isChannelIDInList(group2model2channels[group][normalized], channelID)
}
......@@ -50,7 +50,7 @@ func isChannelEnabledForGroupModelDB(group string, modelName string, channelID i
if err == nil && count > 0 {
return true
}
normalized := ratio_setting.FormatMatchingModelName(modelName)
normalized := ratio_setting.RoutingMatchModelName(modelName)
if normalized == "" || normalized == modelName {
return false
}
......
......@@ -37,6 +37,43 @@ func TestConvertClaudeRequestTreatsZeroMaxTokensAsUnset(t *testing.T) {
assert.Equal(t, uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model)), *converted.MaxTokens)
}
func TestConvertClaudeRequestPreservesNativeClaudeCodeThinking(t *testing.T) {
budget := 10000
maxTokens := uint(20000)
temperature := 0.7
topP := 0.9
req := &dto.ClaudeRequest{
Model: "claude-opus-4-8",
MaxTokens: &maxTokens,
Temperature: &temperature,
TopP: &topP,
Thinking: &dto.Thinking{Type: "enabled", BudgetTokens: &budget},
OutputConfig: []byte(`{"effort":"high"}`),
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
OriginModelName: req.Model,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: req.Model,
},
}
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
require.NotNil(t, converted.Thinking)
assert.Equal(t, "enabled", converted.Thinking.Type)
require.NotNil(t, converted.Thinking.BudgetTokens)
assert.Equal(t, budget, *converted.Thinking.BudgetTokens)
assert.Equal(t, temperature, *converted.Temperature)
assert.Equal(t, topP, *converted.TopP)
assert.JSONEq(t, `{"effort":"high"}`, string(converted.OutputConfig))
assert.Empty(t, info.ConversionDiagnostics())
}
func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
......@@ -59,7 +96,8 @@ func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T
outbound, err := common.DeepCopy(original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
err = helper.ApplyReasoningModelSuffix(nil, info, outbound)
require.NoError(t, err)
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound)
require.NoError(t, err)
......
......@@ -342,7 +342,8 @@ func applyOpenAIChatReasoningThroughHandlerOrder(t *testing.T, original dto.Gene
outbound, err := common.DeepCopy(&original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
err = helper.ApplyReasoningModelSuffix(nil, info, outbound)
require.NoError(t, err)
return outbound, info
}
......
......@@ -272,7 +272,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if len(request.Usage) == 0 {
request.Usage = json.RawMessage(`{"include":true}`)
}
// 适配 OpenRouter 的 thinking 后缀
// 合并 effort 尾巴产生的意图
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
mergeEffortSuffix := func(modelName string) error {
rawEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName)
......@@ -304,28 +304,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
}
if !preserveSuffix && strings.HasSuffix(info.UpstreamModelName, "-thinking") {
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
initialIntent,
kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
info.UpstreamModelName,
)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
request.Model = info.UpstreamModelName
}
if !preserveSuffix && info.OriginModelName != info.UpstreamModelName && strings.HasSuffix(info.OriginModelName, "-thinking") {
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
initialIntent,
kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
info.OriginModelName,
)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
}
if !initialIntent.IsEmpty() {
reasoningConfig := make(map[string]any)
if len(request.Reasoning) > 0 {
......
......@@ -38,7 +38,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
......
......@@ -536,6 +536,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
reqId = common.NewRequestId()
}
reasoningEffort := reasoningEffortFromRequest(request)
originModelName := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
info := &RelayInfo{
Request: request,
ReasoningEffort: reasoningEffort,
......@@ -547,7 +548,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
UserQuota: common.GetContextKeyInt(c, constant.ContextKeyUserQuota),
UserEmail: common.GetContextKeyString(c, constant.ContextKeyUserEmail),
OriginModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel),
OriginModelName: originModelName,
TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId),
TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey),
......
......@@ -161,6 +161,20 @@ func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
}
}
func TestGenRelayInfoKeepsOriginAndLeavesBillingUnset(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
const model = "qwen3.8-max@thinking:on@temperature:0.2"
ctx.Set("original_model", model)
info, err := GenRelayInfo(ctx, types.RelayFormatOpenAI, &dto.GeneralOpenAIRequest{Model: model}, nil)
require.NoError(t, err)
assert.Equal(t, model, info.OriginModelName)
assert.Empty(t, info.BillingModelName)
assert.Equal(t, model, info.GetBillingModelName())
}
func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
......
......@@ -43,7 +43,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
......
......@@ -7,7 +7,9 @@ import (
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
......@@ -62,6 +64,30 @@ func TestOptInSafeToolLossRejectedAsBadRequestWithAdminDiagnostics(t *testing.T)
require.Contains(t, adminInfo, "conversion_diagnostics")
}
func TestUnknownModelModifierIsBadRequestWithoutRetry(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "m@thinkin:on",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "m@thinkin:on",
},
}
err := helper.ApplyReasoningModelSuffix(c, info)
require.Error(t, err)
require.True(t, kitreasoning.IsClientError(err))
assert.Contains(t, err.Error(), `unsupported model modifier "thinkin"`)
assert.Contains(t, err.Error(), "re:")
apiErr := newConvertRequestFailedError(c, info, err)
require.NotNil(t, apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
assert.Equal(t, types.ErrorCodeConvertRequestFailed, apiErr.GetErrorCode())
assert.True(t, types.IsSkipRetryError(apiErr))
}
func hasHostDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
......
......@@ -37,7 +37,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
......@@ -198,7 +198,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, req); err != nil {
if err := helper.ApplyReasoningModelSuffix(c, info, req); err != nil {
return newConvertRequestFailedError(c, info, err)
}
......
package helper
import (
"encoding/json"
"errors"
"fmt"
"github.com/QuantumNous/new-api/relay/common"
rootcommon "github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
)
func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request dto.Request) error {
func ModelMappedHelper(c *gin.Context, info *relaycommon.RelayInfo, request dto.Request) error {
if info.ChannelMeta == nil {
info.ChannelMeta = &common.ChannelMeta{}
info.ChannelMeta = &relaycommon.ChannelMeta{}
}
// map model name
modelMapping := c.GetString("model_mapping")
if modelMapping != "" && modelMapping != "{}" {
modelMap := make(map[string]string)
err := json.Unmarshal([]byte(modelMapping), &modelMap)
err := rootcommon.Unmarshal([]byte(modelMapping), &modelMap)
if err != nil {
return fmt.Errorf("unmarshal_model_mapping_failed")
}
......@@ -30,18 +31,23 @@ func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request dto.Reque
currentModel: true,
}
for {
if mappedModel, exists := modelMap[currentModel]; exists && mappedModel != "" {
mappedModel, exists := modelMap[currentModel]
baseModel := hostreasoning.BaseModelName(currentModel)
if (!exists || mappedModel == "") && baseModel != currentModel {
mappedModel, exists = modelMap[baseModel]
}
if exists && mappedModel != "" {
// 模型重定向循环检测,避免无限循环
if visitedModels[mappedModel] {
if mappedModel == currentModel {
if currentModel == info.OriginModelName {
info.IsModelMapped = false
return nil
} else {
}
info.IsModelMapped = true
break
}
}
return errors.New("model_mapping_contains_cycle")
}
visitedModels[mappedModel] = true
......
package helper
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
type parsedModelModifiers struct {
base string
hasSyntax bool
intent reasoning.Intent
hasThinking bool
temperature *float64
topP *float64
hasTemperature bool
hasTopP bool
diagnostics []types.ConversionDiagnostic
}
const modelModifierExemptionHint = `If this segment is part of the real model name, add the model to the "Models that skip thinking suffix processing" setting (re: regex entries are supported)`
func modelModifierClientError(message string) error {
return fmt.Errorf("%s. %s", message, modelModifierExemptionHint)
}
func parseExplicitModelModifiers(modelName string) (parsedModelModifiers, error) {
spec := reasoning.ParseModelModifiers(modelName)
parsed := parsedModelModifiers{base: spec.Base, hasSyntax: spec.HasModifiers()}
last := make(map[string]int, len(spec.Modifiers))
for index, modifier := range spec.Modifiers {
if _, duplicate := last[modifier.Key]; duplicate {
parsed.diagnostics = append(parsed.diagnostics, modelModifierDiagnostic(
"duplicate_model_modifier",
modifier.Key,
fmt.Sprintf("model modifier %q is repeated; the rightmost value is used", modifier.Key),
))
}
last[modifier.Key] = index
}
for index, modifier := range spec.Modifiers {
if last[modifier.Key] != index {
continue
}
switch modifier.Key {
case "thinking":
intent, ok := reasoning.ParseThinkingModifier(modifier.Value)
if !ok {
return parsedModelModifiers{}, modelModifierClientError(
fmt.Sprintf("invalid thinking modifier value %q", modifier.Value),
)
}
if parsed.hasThinking && intent.Mode != reasoning.ModeDisabled && intent.BudgetTokens == nil {
parsed.intent.Mode = intent.Mode
parsed.intent.Source = reasoning.SourceSuffix
} else if parsed.hasThinking && intent.Mode != reasoning.ModeDisabled {
parsed.intent.Mode = intent.Mode
parsed.intent.BudgetTokens = intent.BudgetTokens
parsed.intent.BudgetSource = intent.BudgetSource
parsed.intent.Source = reasoning.SourceSuffix
} else {
parsed.intent = intent
}
parsed.hasThinking = true
case "effort":
effort, err := reasoning.ParseEffort(modifier.Value)
if err != nil || effort == "" {
return parsedModelModifiers{}, modelModifierClientError(
fmt.Sprintf("invalid effort modifier value %q: must be one of none/low/medium/high/xhigh/max", modifier.Value),
)
}
if effort == reasoning.EffortNone {
parsed.intent = reasoning.Intent{Mode: reasoning.ModeDisabled, Effort: reasoning.EffortNone, Source: reasoning.SourceSuffix}
} else {
if parsed.intent.Mode == reasoning.ModeUnset || parsed.intent.Mode == reasoning.ModeDisabled {
parsed.intent.Mode = reasoning.ModeEnabled
}
parsed.intent.Effort = effort
parsed.intent.Source = reasoning.SourceSuffix
}
parsed.hasThinking = true
case "temperature":
value, ok := parseFiniteFloat(modifier.Value)
if !ok {
return parsedModelModifiers{}, modelModifierClientError(
fmt.Sprintf("invalid temperature modifier value %q: must be a finite number", modifier.Value),
)
}
parsed.temperature = &value
parsed.hasTemperature = true
case "topp":
value, ok := parseFiniteFloat(modifier.Value)
if !ok {
return parsedModelModifiers{}, modelModifierClientError(
fmt.Sprintf("invalid topp modifier value %q: must be a finite number", modifier.Value),
)
}
parsed.topP = &value
parsed.hasTopP = true
default:
return parsedModelModifiers{}, modelModifierClientError(
fmt.Sprintf("unsupported model modifier %q", modifier.Key),
)
}
}
return parsed, nil
}
func parseFiniteFloat(raw string) (float64, bool) {
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
return 0, false
}
return value, true
}
func extractTemperature(req dto.Request) (float64, bool) {
switch request := req.(type) {
case *dto.GeneralOpenAIRequest:
if request != nil && request.Temperature != nil {
return *request.Temperature, true
}
case *dto.OpenAIResponsesRequest:
if request != nil && request.Temperature != nil {
return *request.Temperature, true
}
case *dto.ClaudeRequest:
if request != nil && request.Temperature != nil {
return *request.Temperature, true
}
case *dto.GeminiChatRequest:
if request != nil && request.GenerationConfig.Temperature != nil {
return *request.GenerationConfig.Temperature, true
}
}
return 0, false
}
func extractTopP(req dto.Request) (float64, bool) {
switch request := req.(type) {
case *dto.GeneralOpenAIRequest:
if request != nil && request.TopP != nil {
return *request.TopP, true
}
case *dto.OpenAIResponsesRequest:
if request != nil && request.TopP != nil {
return *request.TopP, true
}
case *dto.ClaudeRequest:
if request != nil && request.TopP != nil {
return *request.TopP, true
}
case *dto.GeminiChatRequest:
if request != nil && request.GenerationConfig.TopP != nil {
return *request.GenerationConfig.TopP, true
}
}
return 0, false
}
func modelModifierDiagnostic(code string, key string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{
Code: code,
Path: "model.@" + key,
Message: message,
Severity: types.ConversionDiagnosticWarning,
}
}
func applyModelControls(req dto.Request, parsed parsedModelModifiers) error {
if req == nil {
return nil
}
switch request := req.(type) {
case *dto.GeneralOpenAIRequest:
if parsed.hasTemperature {
request.Temperature = parsed.temperature
}
if parsed.hasTopP {
request.TopP = parsed.topP
}
if parsed.hasThinking {
request.ReasoningConversion = reasoning.StateFromIntent(parsed.intent)
reasoningConfig := make(map[string]any)
if len(request.Reasoning) > 0 {
if common.GetJsonType(request.Reasoning) != "object" {
return fmt.Errorf("OpenAI reasoning must be a JSON object")
}
if err := common.Unmarshal(request.Reasoning, &reasoningConfig); err != nil {
return fmt.Errorf("invalid OpenAI reasoning config: %w", err)
}
}
if parsed.intent.BudgetTokens != nil {
reasoningConfig["enabled"] = parsed.intent.Mode != reasoning.ModeDisabled
reasoningConfig["max_tokens"] = *parsed.intent.BudgetTokens
delete(reasoningConfig, "effort")
request.ReasoningEffort = ""
} else {
delete(reasoningConfig, "enabled")
delete(reasoningConfig, "effort")
delete(reasoningConfig, "max_tokens")
request.ReasoningEffort = ""
if parsed.intent.Effort != "" {
request.ReasoningEffort = string(reasoning.OpenAIEffort(parsed.intent.Effort))
}
}
if len(reasoningConfig) == 0 {
request.Reasoning = nil
} else {
encoded, err := common.Marshal(reasoningConfig)
if err != nil {
return err
}
request.Reasoning = encoded
}
}
case *dto.OpenAIResponsesRequest:
if parsed.hasTemperature {
request.Temperature = parsed.temperature
}
if parsed.hasTopP {
request.TopP = parsed.topP
}
if parsed.hasThinking {
request.ReasoningConversion = reasoning.StateFromIntent(parsed.intent)
if parsed.intent.Effort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
request.Reasoning.Effort = string(reasoning.OpenAIEffort(parsed.intent.Effort))
} else if request.Reasoning != nil && parsed.intent.BudgetTokens == nil {
request.Reasoning.Effort = ""
}
}
case *dto.ClaudeRequest:
if parsed.hasTemperature {
request.Temperature = parsed.temperature
}
if parsed.hasTopP {
request.TopP = parsed.topP
}
if parsed.hasThinking {
request.Thinking = nil
if len(request.OutputConfig) > 0 && common.GetJsonType(request.OutputConfig) == "object" {
var output map[string]any
if err := common.Unmarshal(request.OutputConfig, &output); err == nil {
delete(output, "effort")
encoded, err := common.Marshal(output)
if err != nil {
return err
}
request.OutputConfig = encoded
}
}
}
case *dto.GeminiChatRequest:
if parsed.hasTemperature {
request.GenerationConfig.Temperature = parsed.temperature
}
if parsed.hasTopP {
request.GenerationConfig.TopP = parsed.topP
}
if parsed.hasThinking {
request.GenerationConfig.ThinkingConfig = nil
}
}
return nil
}
......@@ -9,10 +9,12 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
......@@ -71,6 +73,11 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hostty
}
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (hosttypes.PriceData, error) {
if info != nil {
if matched := resolveBillingModelName(info.GetOriginModelName()); matched != "" && matched != info.OriginModelName {
info.BillingModelName = matched
}
}
billingModelName := info.GetBillingModelName()
modelPrice, usePrice := ratio_setting.GetModelPrice(billingModelName, false)
......@@ -267,6 +274,50 @@ func HasModelBillingConfig(modelName string) bool {
return ok && strings.TrimSpace(expr) != ""
}
// HasPriceOrRatioEntry reports whether name has a configured price, ratio, or
// tiered billing-mode entry after a single wildcard normalization. Self-use
// fallback does not count as a configured ratio.
func HasPriceOrRatioEntry(name string) bool {
formatted := ratio_setting.FormatMatchingModelName(name)
if _, ok := ratio_setting.GetModelPrice(formatted, false); ok {
return true
}
if ratio_setting.HasConfiguredModelRatio(formatted) {
return true
}
return billing_setting.GetBillingMode(formatted) == billing_setting.BillingModeTieredExpr
}
func resolveBillingModelName(origin string) string {
var candidates []string
if !reasoning.ParseModelModifiers(origin).HasModifiers() {
candidates = append(candidates, origin)
}
candidates = append(candidates, hostreasoning.CanonicalBillingModelNames(origin)...)
base := hostreasoning.BaseModelName(origin)
candidates = append(candidates, base)
seen := make(map[string]struct{}, len(candidates))
matched := ""
for _, name := range candidates {
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
if HasPriceOrRatioEntry(name) {
matched = name
break
}
}
if matched == "" {
matched = base
}
return matched
}
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
exprStr, ok := billing_setting.GetBillingExpr(billingModelName)
if !ok {
......
......@@ -70,7 +70,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
if err := helper.ApplyReasoningModelSuffix(c, info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
......
package convdiag
import (
"context"
"reflect"
"github.com/QuantumNous/new-api/relaykit/types"
)
type collectorKey struct{}
type Collector struct {
diagnostics []types.ConversionDiagnostic
}
func WithCollector(ctx context.Context) (context.Context, *Collector) {
if isNilContext(ctx) {
ctx = context.Background()
}
if collector, _ := ctx.Value(collectorKey{}).(*Collector); collector != nil {
return ctx, collector
}
collector := &Collector{}
return context.WithValue(ctx, collectorKey{}, collector), collector
}
func Add(ctx context.Context, diagnostics ...types.ConversionDiagnostic) {
if isNilContext(ctx) || len(diagnostics) == 0 {
return
}
collector, _ := ctx.Value(collectorKey{}).(*Collector)
if collector == nil {
return
}
collector.diagnostics = append(collector.diagnostics, diagnostics...)
}
func isNilContext(ctx context.Context) bool {
if ctx == nil {
return true
}
value := reflect.ValueOf(ctx)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func (c *Collector) Diagnostics() []types.ConversionDiagnostic {
if c == nil || len(c.diagnostics) == 0 {
return nil
}
return append([]types.ConversionDiagnostic(nil), c.diagnostics...)
}
......@@ -97,7 +97,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(&claudeRequest, info, sourceReasoning); err != nil {
if err := sharedclaude.ApplyReasoning(c, &claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
......
......@@ -60,7 +60,7 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(claudeRequest, info, sourceReasoning); err != nil {
if err := sharedclaude.ApplyReasoning(c, claudeRequest, info, sourceReasoning, true); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
......
package claude
import (
"context"
"fmt"
"math"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
)
func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning.Intent) error {
func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.Meta, source reasoning.Intent, crossProtocol bool) error {
if req == nil {
return nil
}
native, err := reasoning.FromClaude(req)
if err != nil {
return err
}
explicit, err := reasoning.MergeExplicit(native, source, req.Model)
if err != nil {
return err
}
opts := convmeta.OptionsOf(info)
baseModel := req.Model
capabilityModel := baseModel
......@@ -35,26 +29,40 @@ func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning
if preserveSuffix {
suffix = reasoning.Intent{}
}
if info != nil && !reasoning.IsKnownClaudeModel(capabilityModel) && reasoning.IsKnownClaudeModel(info.GetOriginModelName()) {
capabilityModel = info.GetOriginModelName()
}
intent, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, req.Model)
// A native Claude request without a host modifier is already in the target
// protocol, including Claude-compatible proxies that keep native controls
// instead of applying Anthropic model rules. Read portable effort for
// accounting metadata, but do not run the capability renderer or rewrite
// provider-native controls.
if !crossProtocol && source.IsEmpty() && suffix.IsEmpty() {
native, err := reasoning.FromClaude(req)
if err != nil {
return err
}
knownClaudeModel := reasoning.IsKnownClaudeModel(capabilityModel)
if source.IsEmpty() && suffix.IsEmpty() && !knownClaudeModel {
// A native Messages request can target a non-Anthropic model through a
// Claude-compatible proxy. Its capability vocabulary belongs to that
// upstream, so preserve validated native controls instead of applying
// Anthropic model rules to an unknown model name.
if info != nil {
if effort := reasoning.EffectiveEffort(intent); effort != "" {
if effort := reasoning.EffectiveEffort(native); effort != "" {
info.SetReasoningEffort(string(effort))
}
}
return nil
}
native, err := reasoning.FromClaude(req)
if err != nil {
return err
}
explicit, err := reasoning.MergeExplicit(native, source, req.Model)
if err != nil {
return err
}
if info != nil && !reasoning.IsKnownClaudeModel(capabilityModel) && reasoning.IsKnownClaudeModel(info.GetOriginModelName()) {
capabilityModel = info.GetOriginModelName()
}
intent, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, req.Model)
if err != nil {
return err
}
knownClaudeModel := reasoning.IsKnownClaudeModel(capabilityModel)
if !knownClaudeModel && intent.Mode == reasoning.ModeAdaptive {
// Cross-protocol pivots cannot safely assume that an unknown
// Claude-compatible model implements Anthropic's adaptive mode. Render
......@@ -93,6 +101,7 @@ func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning
if err != nil {
return err
}
convdiag.Add(ctx, rendered.Diagnostics...)
req.Model = baseModel
if rendered.Thinking != nil {
req.Thinking = rendered.Thinking
......@@ -118,15 +127,34 @@ func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning
req.OutputConfig = encoded
}
if rendered.ClearSampling {
if req.Temperature != nil || req.TopP != nil || req.TopK != nil {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "claude_sampling_removed",
Path: "temperature/top_p/top_k",
Message: fmt.Sprintf("model %q does not accept sampling controls with the selected thinking mode", capabilityModel),
Severity: types.ConversionDiagnosticWarning,
To: types.RelayFormatClaude,
})
}
req.Temperature = nil
req.TopP = nil
req.TopK = nil
} else if rendered.ConstrainThinkingSampling {
removedSampling := req.Temperature != nil || req.TopK != nil || req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1)
req.Temperature = nil
req.TopK = nil
if req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1) {
req.TopP = nil
}
if removedSampling {
convdiag.Add(ctx, types.ConversionDiagnostic{
Code: "claude_sampling_constrained",
Path: "temperature/top_p/top_k",
Message: fmt.Sprintf("model %q accepts only top_p between 0.95 and 1 with manual thinking", capabilityModel),
Severity: types.ConversionDiagnosticWarning,
To: types.RelayFormatClaude,
})
}
}
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))
......
......@@ -81,6 +81,7 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
modelName := convmeta.UpstreamModelName(info)
var source reasoning.Intent
crossProtocol := len(oaiRequest) > 0
if len(oaiRequest) > 0 {
if modelName == "" {
modelName = oaiRequest[0].Model
......@@ -101,6 +102,21 @@ func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Met
if preserveSuffix {
suffix = reasoning.Intent{}
}
// Native Gemini requests already use the target protocol. Without a host
// modifier, read portable effort metadata without running the capability
// renderer or rewriting provider-native controls.
if !crossProtocol && suffix.IsEmpty() {
native, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return err
}
if info != nil {
if effort := reasoning.EffectiveEffort(native); effort != "" {
info.SetReasoningEffort(string(effort))
}
}
return nil
}
native, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return err
......
......@@ -6,6 +6,7 @@ import (
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
)
type ClaudeRender struct {
......@@ -14,6 +15,7 @@ type ClaudeRender struct {
EffectiveEffort Effort
ClearSampling bool
ConstrainThinkingSampling bool
Diagnostics []types.ConversionDiagnostic
}
type claudeCapabilities struct {
......@@ -73,7 +75,8 @@ func claudeCapabilitiesFor(model string) claudeCapabilities {
}
func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPercentage float64) (ClaudeRender, error) {
if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
disabledWithEffort := intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone
if disabledWithEffort {
effort, err := ParseEffort(string(intent.Effort))
if err != nil {
return ClaudeRender{}, err
......@@ -87,6 +90,13 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
}
}
capabilities := claudeCapabilitiesFor(model)
diagnostics := make([]types.ConversionDiagnostic, 0, 1)
if disabledWithEffort {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_disabled_effort_ignored",
fmt.Sprintf("model %q cannot apply effort %q while thinking is disabled; the effort was ignored", model, intent.Effort),
))
}
if !intent.HasStrength() {
if intent.IncludeThoughts != nil && capabilities.adaptive && capabilities.defaultThinking {
thinking := &dto.Thinking{Type: "adaptive"}
......@@ -108,23 +118,50 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
}
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
if strings.HasPrefix(strings.ToLower(model), "claude-opus-5") &&
(intent.Effort == EffortXHigh || intent.Effort == EffortMax) {
return ClaudeRender{}, fmt.Errorf("model %q does not support effort %q while thinking is disabled", model, intent.Effort)
}
if !capabilities.supportsDisable {
return ClaudeRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_thinking_disable_unsupported",
fmt.Sprintf("model %q cannot disable thinking; using the lowest representable thinking mode", model),
))
if capabilities.adaptive {
thinking := &dto.Thinking{Type: "adaptive"}
if intent.IncludeThoughts != nil {
if *intent.IncludeThoughts {
thinking.Display = "summarized"
} else {
thinking.Display = "omitted"
}
}
outputEffort := Effort("")
effectiveEffort := EffortHigh
if capabilities.supportsEffort {
outputEffort = EffortLow
effectiveEffort = EffortLow
}
return ClaudeRender{
Thinking: thinking,
OutputEffort: outputEffort,
EffectiveEffort: effectiveEffort,
ClearSampling: capabilities.strictSampling,
Diagnostics: diagnostics,
}, nil
}
return ClaudeRender{Diagnostics: diagnostics}, nil
}
return ClaudeRender{
Thinking: &dto.Thinking{Type: "disabled"},
EffectiveEffort: EffortNone,
ClearSampling: capabilities.strictSampling,
Diagnostics: diagnostics,
}, nil
}
preferManual := capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
if !capabilities.supportsManual && intent.BudgetTokens != nil && intent.BudgetSource == SourceNative && intent.Mode == ModeEnabled {
return ClaudeRender{}, fmt.Errorf("model %q requires adaptive thinking and does not support native budget_tokens", model)
if !capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode == ModeEnabled {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_budget_to_adaptive",
fmt.Sprintf("model %q uses adaptive thinking; budget_tokens was converted to an effort level", model),
))
}
if capabilities.adaptive && !preferManual {
effort := intent.Effort
......@@ -134,7 +171,14 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
if effort == "" && intent.Mode == ModeEnabled {
effort = EffortHigh
}
effort = normalizeClaudeEffort(effort, capabilities)
normalizedEffort := normalizeClaudeEffort(effort, capabilities)
if effort != "" && normalizedEffort != effort {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_effort_adjusted",
fmt.Sprintf("model %q does not support effort %q; using %q", model, effort, normalizedEffort),
))
}
effort = normalizedEffort
effectiveEffort := effort
if effectiveEffort == "" && intent.Mode == ModeAdaptive {
effectiveEffort = EffortHigh
......@@ -148,6 +192,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
OutputEffort: effort,
EffectiveEffort: effectiveEffort,
ClearSampling: capabilities.strictSampling,
Diagnostics: diagnostics,
}, nil
}
......@@ -165,11 +210,19 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
EffectiveEffort: effectiveEffort,
ClearSampling: capabilities.strictSampling,
ConstrainThinkingSampling: !capabilities.strictSampling,
Diagnostics: diagnostics,
}, nil
}
if intent.Mode == ModeAdaptive {
return ClaudeRender{}, fmt.Errorf("model %q does not support adaptive thinking", model)
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_adaptive_to_manual",
fmt.Sprintf("model %q does not support adaptive thinking; using manual thinking", model),
))
intent.Mode = ModeEnabled
if intent.Effort == "" {
intent.Effort = EffortHigh
}
}
if intent.Mode == ModeUnset {
return ClaudeRender{OutputEffort: intent.Effort, EffectiveEffort: intent.Effort}, nil
......@@ -185,23 +238,28 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
}
budget := 0
if intent.BudgetTokens != nil && *intent.BudgetTokens == -1 && intent.BudgetSource == SourceNative {
return ClaudeRender{}, fmt.Errorf("Claude thinking budget_tokens does not support -1")
}
if intent.BudgetTokens != nil && *intent.BudgetTokens >= 0 {
budget = *intent.BudgetTokens
if intent.BudgetSource != SourceNative {
requestedBudget := *intent.BudgetTokens
budget = requestedBudget
if budget < 1024 {
budget = 1024
}
if uint(budget) >= *maxTokens {
budget = int(*maxTokens) - 1
}
}
if budget < 1024 || uint(budget) >= *maxTokens {
return ClaudeRender{}, fmt.Errorf("Claude thinking budget must satisfy 1024 <= budget_tokens < max_tokens")
if budget != requestedBudget {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_budget_adjusted",
fmt.Sprintf("model %q requires 1024 <= budget_tokens < max_tokens; adjusted %d to %d", model, requestedBudget, budget),
))
}
} else {
if intent.BudgetTokens != nil {
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
"claude_dynamic_budget_converted",
fmt.Sprintf("model %q does not support a dynamic budget; derived a manual budget from reasoning effort", model),
))
}
percentage := effortPercentage(intent.Effort, adapterBudgetPercentage)
budget = int(*maxTokens) * percentage / 100
if budget < 1024 {
......@@ -236,9 +294,20 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
OutputEffort: outputEffort,
EffectiveEffort: effectiveEffort,
ConstrainThinkingSampling: true,
Diagnostics: diagnostics,
}, nil
}
func claudeReasoningDiagnostic(code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{
Code: code,
Path: "thinking",
Message: message,
Severity: types.ConversionDiagnosticWarning,
To: types.RelayFormatClaude,
}
}
// ClaudeUsesManualThinking reports whether an exact numeric budget is rendered
// as legacy extended thinking rather than being reduced to adaptive effort.
func ClaudeUsesManualThinking(model string, intent Intent) bool {
......
......@@ -2,6 +2,7 @@ package reasoning
import (
"fmt"
"regexp"
"strconv"
"strings"
......@@ -14,6 +15,102 @@ var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low"
var DeepSeekV4EffortSuffixes = []string{"-none", "-max"}
var (
legacyOpenAIModelPattern = regexp.MustCompile(`^(gpt-[a-z0-9][a-z0-9._-]*|o[1-9][a-z0-9._-]*)$`)
legacyClaudeModelPattern = regexp.MustCompile(`^claude-[a-z0-9][a-z0-9._-]*$`)
legacyGeminiModelPattern = regexp.MustCompile(`^gemini-[a-z0-9][a-z0-9._-]*$`)
)
type ModelModifier struct {
Key string
Value string
}
type ModelModifierSpec struct {
Raw string
Base string
Modifiers []ModelModifier
}
func (s ModelModifierSpec) HasModifiers() bool {
return len(s.Modifiers) > 0
}
// ParseModelModifiers removes only a contiguous trailing chain of @key:value
// segments. Other @ characters remain part of the opaque model name.
func ParseModelModifiers(modelName string) ModelModifierSpec {
spec := ModelModifierSpec{Raw: modelName, Base: modelName}
parts := strings.Split(modelName, "@")
if len(parts) < 2 {
return spec
}
firstModifier := len(parts)
for i := len(parts) - 1; i > 0; i-- {
key, value, ok := parseModelModifierSegment(parts[i])
if !ok {
break
}
firstModifier = i
spec.Modifiers = append([]ModelModifier{{Key: key, Value: value}}, spec.Modifiers...)
}
if firstModifier == len(parts) {
return spec
}
base := strings.Join(parts[:firstModifier], "@")
if base == "" {
return ModelModifierSpec{Raw: modelName, Base: modelName}
}
spec.Base = base
return spec
}
func parseModelModifierSegment(segment string) (string, string, bool) {
colon := strings.IndexByte(segment, ':')
if colon <= 0 {
return "", "", false
}
key := segment[:colon]
for i, r := range key {
letter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
if i == 0 && !letter || i > 0 && !letter && (r < '0' || r > '9') && r != '_' && r != '-' {
return "", "", false
}
}
return strings.ToLower(key), segment[colon+1:], true
}
// ParseThinkingModifier maps an explicit @thinking value onto a portable
// Intent. on/adaptive/off and integer budgets (including -1) are accepted;
// values below -1 are rejected.
func ParseThinkingModifier(raw string) (Intent, bool) {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "on":
return Intent{Mode: ModeEnabled, Source: SourceSuffix}, true
case "adaptive":
return Intent{Mode: ModeAdaptive, Source: SourceSuffix}, true
case "off":
return Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true
}
if budget, err := strconv.Atoi(value); err == nil {
if budget < -1 {
return Intent{}, false
}
if budget == 0 {
return Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true
}
return Intent{
Mode: ModeEnabled,
BudgetTokens: &budget,
Source: SourceSuffix,
BudgetSource: SourceSuffix,
}, true
}
return Intent{}, false
}
func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string, string, bool) {
suffix, found := lo.Find(suffixes, func(s string) bool {
return strings.HasSuffix(modelName, s)
......@@ -24,37 +121,31 @@ func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string,
return strings.TrimSuffix(modelName, suffix), strings.TrimPrefix(suffix, "-"), true
}
// ParseOpenAIReasoningEffortFromModelSuffix extracts an OpenAI effort tail
// such as -high or -none. preserveEffortTail, when non-nil, keeps real model
// IDs whose names already end in those tokens (for example qwen-max).
// ParseOpenAIReasoningEffortFromModelSuffix extracts an effort tail only from
// GPT and o-series model families. preserveEffortTail is consulted on the
// complete name first so real model IDs that already end in an effort word
// (for example gpt-5.1-codex-max) stay intact.
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string, preserveEffortTail func(string) bool) (string, string) {
if preserveEffortTail != nil && preserveEffortTail(modelName) {
return "", modelName
}
baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes)
if !ok {
if !ok || !legacyOpenAIModelPattern.MatchString(lastModelPathSegment(baseModel)) {
return "", modelName
}
return effort, baseModel
}
func ParseClaudeModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
if !strings.HasPrefix(modelName, "claude-") {
prefix, bare := splitModelNamespace(modelName)
if !strings.HasPrefix(bare, "claude-") {
return modelName, Intent{}, false, nil
}
if allowThinkingAlias && hasLegacyThinkingAlias(modelName) {
return parseProviderModelSuffix(modelName, "claude-", true, true)
}
if !isKnownClaudeModel(modelName) {
return modelName, Intent{}, false, nil
base, intent, found, err := parseProviderModelSuffix(bare, "claude-", allowThinkingAlias, true)
if err != nil || !found || !legacyClaudeModelPattern.MatchString(base) {
return modelName, Intent{}, false, err
}
return parseProviderModelSuffix(modelName, "claude-", allowThinkingAlias, true)
}
func hasLegacyThinkingAlias(modelName string) bool {
return strings.HasSuffix(modelName, "-thinking") ||
strings.HasSuffix(modelName, "-nothinking") ||
strings.LastIndex(modelName, "-thinking-") >= 0
return prefix + base, intent, true, nil
}
func isKnownClaudeModel(modelName string) bool {
......@@ -81,36 +172,41 @@ func isKnownClaudeModel(modelName string) bool {
}
func ParseGeminiModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
if !strings.HasPrefix(modelName, "gemini-") {
prefix, bare := splitModelNamespace(modelName)
if !strings.HasPrefix(bare, "gemini-") {
return modelName, Intent{}, false, nil
}
if !isKnownGeminiModel(modelName) {
return modelName, Intent{}, false, nil
base, intent, found, err := parseProviderModelSuffix(bare, "gemini-", allowThinkingAlias, true)
if err != nil || !found || !legacyGeminiModelPattern.MatchString(base) {
return modelName, Intent{}, false, err
}
return parseProviderModelSuffix(modelName, "gemini-", allowThinkingAlias, true)
return prefix + base, intent, true, nil
}
// ParseKnownProviderModelSuffix extracts a canonical intent only when the
// origin identifies a provider family whose suffix vocabulary is defined by
// relaykit. Unknown OpenAI-compatible model names are deliberately untouched.
func ParseKnownProviderModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
if strings.HasPrefix(modelName, "claude-") {
bare := lastModelPathSegment(modelName)
if strings.HasPrefix(bare, "claude-") {
return ParseClaudeModelSuffix(modelName, allowThinkingAlias)
}
if strings.HasPrefix(modelName, "gemini-") {
if strings.HasPrefix(bare, "gemini-") {
return ParseGeminiModelSuffix(modelName, allowThinkingAlias)
}
return modelName, Intent{}, false, nil
}
func isKnownGeminiModel(modelName string) bool {
baseModel, _, _ := TrimEffortSuffixWithSuffixes(modelName, []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"})
if marker := strings.LastIndex(baseModel, "-thinking-"); marker >= 0 {
baseModel = baseModel[:marker]
} else {
baseModel = strings.TrimSuffix(strings.TrimSuffix(baseModel, "-thinking"), "-nothinking")
func splitModelNamespace(modelName string) (string, string) {
if slash := strings.LastIndex(modelName, "/"); slash >= 0 {
return modelName[:slash+1], modelName[slash+1:]
}
return geminiCapabilitiesFor(baseModel).kind != geminiThinkingUnknown
return "", modelName
}
func lastModelPathSegment(modelName string) string {
_, bare := splitModelNamespace(modelName)
return bare
}
func TrimGeminiThinkingSuffix(modelName string) (string, bool) {
......
......@@ -133,6 +133,58 @@ func TestParseKnownProviderModelSuffix(t *testing.T) {
assert.Empty(t, effort)
assert.Equal(t, "vendor/qwen-max", base)
})
t.Run("preserve gpt-5.1-codex-max with callback", func(t *testing.T) {
t.Parallel()
preserve := func(name string) bool { return name == "gpt-5.1-codex-max" }
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max", preserve)
assert.Empty(t, effort)
assert.Equal(t, "gpt-5.1-codex-max", base)
})
t.Run("splits gpt-5.1-codex-max without callback", func(t *testing.T) {
t.Parallel()
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max", nil)
assert.Equal(t, "max", effort)
assert.Equal(t, "gpt-5.1-codex", base)
})
}
func TestParseThinkingModifier(t *testing.T) {
t.Parallel()
on, ok := ParseThinkingModifier("on")
require.True(t, ok)
assert.Equal(t, ModeEnabled, on.Mode)
adaptive, ok := ParseThinkingModifier("Adaptive")
require.True(t, ok)
assert.Equal(t, ModeAdaptive, adaptive.Mode)
off, ok := ParseThinkingModifier("off")
require.True(t, ok)
assert.Equal(t, ModeDisabled, off.Mode)
assert.Equal(t, EffortNone, off.Effort)
zero, ok := ParseThinkingModifier("0")
require.True(t, ok)
assert.Equal(t, ModeDisabled, zero.Mode)
budget, ok := ParseThinkingModifier("8192")
require.True(t, ok)
require.NotNil(t, budget.BudgetTokens)
assert.Equal(t, 8192, *budget.BudgetTokens)
assert.Equal(t, ModeEnabled, budget.Mode)
dynamic, ok := ParseThinkingModifier("-1")
require.True(t, ok)
require.NotNil(t, dynamic.BudgetTokens)
assert.Equal(t, -1, *dynamic.BudgetTokens)
_, ok = ParseThinkingModifier("-2")
assert.False(t, ok)
_, ok = ParseThinkingModifier("enabled")
assert.False(t, ok)
}
func intPtr(v int) *int {
......
......@@ -6,6 +6,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
......@@ -33,7 +34,14 @@ func ApplyGeminiThinkingConfigChecked(geminiRequest *dto.GeminiChatRequest, info
}
func ApplyClaudeThinkingModel(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) error {
return reasoning.AsClientError(sharedclaude.ApplyReasoning(claudeRequest, info, reasoning.Intent{}))
ctx, collector := convdiag.WithCollector(context.Background())
err := reasoning.AsClientError(sharedclaude.ApplyReasoning(ctx, claudeRequest, info, reasoning.Intent{}, false))
if recorder, ok := info.(interface {
RecordConversionDiagnostics(context.Context, []types.ConversionDiagnostic)
}); ok {
recorder.RecordConversionDiagnostics(ctx, collector.Diagnostics())
}
return err
}
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
......
......@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
claudemessages "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/claude_messages"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
oairesponses "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_responses"
......@@ -239,6 +240,7 @@ func executeRequestSpec(c context.Context, info convmeta.Meta, from types.RelayF
}
func executeRequestSteps(c context.Context, info convmeta.Meta, from types.RelayFormat, target types.RelayFormat, request any, converter string, quality RequestConverterQuality, specs []RequestConverterSpec) (*RequestResult, error) {
c, diagnosticCollector := convdiag.WithCollector(c)
current, tools, err := toolconv.ExtractRequest(from, request)
if err != nil {
return nil, err
......@@ -258,7 +260,16 @@ func executeRequestSteps(c context.Context, info convmeta.Meta, from types.Relay
steps = append(steps, step)
}
current, diagnostics, err := toolconv.AttachRequest(target, current, tools, convmeta.OptionsOf(info))
current, toolDiagnostics, err := toolconv.AttachRequest(target, current, tools, convmeta.OptionsOf(info))
diagnostics := append(diagnosticCollector.Diagnostics(), toolDiagnostics...)
for i := range diagnostics {
if diagnostics[i].From == "" {
diagnostics[i].From = from
}
if diagnostics[i].To == "" {
diagnostics[i].To = target
}
}
if err != nil {
return &RequestResult{
Value: current,
......
package model_setting
import (
"fmt"
"regexp"
"slices"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/config"
)
......@@ -35,6 +39,8 @@ func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channe
type GlobalSettings struct {
PassThroughRequestEnabled bool `json:"pass_through_request_enabled"`
ThinkingModelBlacklist []string `json:"thinking_model_blacklist"`
// EffortTailModelIDs lists real model IDs that sit inside the GPT/o-series
// family whitelist but whose names already end in an effort word.
EffortTailModelIDs []string `json:"effort_tail_model_ids"`
ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"`
}
......@@ -71,32 +77,105 @@ func GetGlobalSettings() *GlobalSettings {
return &globalSettings
}
// ShouldPreserveThinkingSuffix 判断模型是否配置为保留 thinking/-nothinking/-low/-high/-medium 后缀
const thinkingBlacklistRegexPrefix = "re:"
type thinkingBlacklistCompiled struct {
source string
exact []string
regexes []*regexp.Regexp
}
var (
thinkingBlacklistMu sync.RWMutex
thinkingBlacklistCache thinkingBlacklistCompiled
)
func thinkingBlacklistSourceKey(entries []string) string {
return strings.Join(entries, "\x00")
}
func compiledThinkingBlacklist() ([]string, []*regexp.Regexp) {
entries := globalSettings.ThinkingModelBlacklist
key := thinkingBlacklistSourceKey(entries)
thinkingBlacklistMu.RLock()
if thinkingBlacklistCache.source == key {
exact, regexes := thinkingBlacklistCache.exact, thinkingBlacklistCache.regexes
thinkingBlacklistMu.RUnlock()
return exact, regexes
}
thinkingBlacklistMu.RUnlock()
thinkingBlacklistMu.Lock()
defer thinkingBlacklistMu.Unlock()
if thinkingBlacklistCache.source == key {
return thinkingBlacklistCache.exact, thinkingBlacklistCache.regexes
}
exact := make([]string, 0, len(entries))
var regexes []*regexp.Regexp
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if strings.HasPrefix(entry, thinkingBlacklistRegexPrefix) {
pattern := strings.TrimPrefix(entry, thinkingBlacklistRegexPrefix)
if pattern == "" {
common.SysError(fmt.Sprintf("invalid thinking_model_blacklist regex %q: pattern is empty", entry))
continue
}
re, err := regexp.Compile(pattern)
if err != nil {
common.SysError(fmt.Sprintf("invalid thinking_model_blacklist regex %q: %v", entry, err))
continue
}
regexes = append(regexes, re)
continue
}
exact = append(exact, entry)
}
thinkingBlacklistCache = thinkingBlacklistCompiled{source: key, exact: exact, regexes: regexes}
return exact, regexes
}
// ShouldPreserveThinkingSuffix reports whether the full model name is exempt
// from host thinking-suffix and @-modifier parsing. Exact blacklist entries
// match the complete name; entries prefixed with re: are Go regular expressions
// matched with MatchString against the same full name.
func ShouldPreserveThinkingSuffix(modelName string) bool {
target := strings.TrimSpace(modelName)
if target == "" {
return false
}
for _, entry := range globalSettings.ThinkingModelBlacklist {
if strings.TrimSpace(entry) == target {
exact, regexes := compiledThinkingBlacklist()
for _, entry := range exact {
if entry == target {
return true
}
}
for _, re := range regexes {
if re.MatchString(target) {
return true
}
}
return false
}
// ShouldPreserveEffortTail reports model IDs whose names already end in an
// effort-like token and must not be treated as reasoning aliases.
// ShouldPreserveEffortTail reports whether modelName is a real model ID whose
// name already ends in an effort word. Entries match the complete name and the
// de-namespaced bare name.
func ShouldPreserveEffortTail(modelName string) bool {
target := strings.TrimSpace(modelName)
if target == "" {
return false
}
bare := target
if slash := strings.LastIndex(bare, "/"); slash >= 0 {
bare = bare[slash+1:]
if slash := strings.LastIndex(target, "/"); slash >= 0 {
bare = target[slash+1:]
}
for _, entry := range globalSettings.EffortTailModelIDs {
entry = strings.TrimSpace(entry)
if entry == "" {
......
package model_setting
import (
"bytes"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldPreserveThinkingSuffixExactAndRegex(t *testing.T) {
settings := GetGlobalSettings()
original := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
assert.True(t, ShouldPreserveThinkingSuffix("kimi-k2-thinking"))
assert.True(t, ShouldPreserveThinkingSuffix("moonshotai/kimi-k2-thinking"))
assert.False(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
settings.ThinkingModelBlacklist = []string{
"kimi-k2-thinking",
"re:[",
"re:",
"re:.*@sha256:.*",
}
var logged bytes.Buffer
previous := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &logged
t.Cleanup(func() { gin.DefaultErrorWriter = previous })
assert.True(t, ShouldPreserveThinkingSuffix("kimi-k2-thinking"))
assert.True(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
assert.False(t, ShouldPreserveThinkingSuffix("m@sha256"))
assert.False(t, ShouldPreserveThinkingSuffix("qwen3-max@thinking:on"))
require.Contains(t, logged.String(), `invalid thinking_model_blacklist regex "re:["`)
require.Contains(t, logged.String(), `invalid thinking_model_blacklist regex "re:"`)
settings.ThinkingModelBlacklist = []string{"re:^beta@"}
assert.False(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
assert.True(t, ShouldPreserveThinkingSuffix("beta@sha256:abc"))
assert.False(t, ShouldPreserveThinkingSuffix("alpha@sha256:abc"))
}
package ratio_setting
import (
"testing"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/stretchr/testify/assert"
)
func TestFormatMatchingModelNameDoesNotStripBase(t *testing.T) {
assert.Equal(t, "qwen3-max@thinking:on", FormatMatchingModelName("qwen3-max@thinking:on"))
assert.Equal(t, "claude-3-7-sonnet-thinking", FormatMatchingModelName("claude-3-7-sonnet-thinking"))
assert.Equal(t, "gemini-2.5-flash-thinking-*", FormatMatchingModelName("gemini-2.5-flash-thinking-8192"))
assert.Equal(t, "gpt-4-gizmo-*", FormatMatchingModelName("gpt-4-gizmo-abc"))
}
func TestRoutingMatchModelNameStripsThenWildcards(t *testing.T) {
assert.Equal(t, "qwen3-max", RoutingMatchModelName("qwen3-max@thinking:on@temperature:0.2"))
assert.Equal(t, "claude-3-7-sonnet", RoutingMatchModelName("claude-3-7-sonnet-thinking"))
assert.Equal(t, "gemini-2.5-flash-thinking-*", RoutingMatchModelName("gemini-2.5-flash-thinking-8192"))
assert.Equal(t, "gpt-5.1-codex-max", RoutingMatchModelName("gpt-5.1-codex-max"))
geminiSettings := model_setting.GetGeminiSettings()
old := geminiSettings.ThinkingAdapterEnabled
geminiSettings.ThinkingAdapterEnabled = true
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = old })
assert.Equal(t, "gemini-2.5-flash", RoutingMatchModelName("gemini-2.5-flash-thinking-8192"))
}
func TestRoutingMatchModelNamePreservesExemptAtName(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
assert.Equal(t, "opaque@sha256:deadbeef", RoutingMatchModelName("opaque@sha256:deadbeef"))
assert.Equal(t, "kimi-k2-thinking", RoutingMatchModelName("kimi-k2-thinking"))
}
......@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/QuantumNous/new-api/types"
)
......@@ -693,9 +694,23 @@ func GetAudioCompletionRatioCopy() map[string]float64 {
return audioCompletionRatioMap.ReadAll()
}
// RoutingMatchModelName returns the name used for channel-ability and token-limit
// fallback matching: strip @ modifiers and legacy aliases first, then apply
// wildcard normalization.
func RoutingMatchModelName(name string) string {
return FormatMatchingModelName(hostreasoning.BaseModelName(name))
}
// HasConfiguredModelRatio reports whether name has an explicit ratio entry
// after wildcard normalization. Self-use fallback does not count.
func HasConfiguredModelRatio(name string) bool {
name = FormatMatchingModelName(name)
_, ok := modelRatioMap.Get(name)
return ok
}
// 转换模型名,减少渠道必须配置各种带参数模型
func FormatMatchingModelName(name string) string {
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*")
} else if strings.HasPrefix(name, "gemini-2.5-flash") {
......
......@@ -4,6 +4,8 @@
package reasoning
import (
"strings"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/setting/model_setting"
)
......@@ -20,8 +22,212 @@ var (
TrimGeminiThinkingSuffix = kitreasoning.TrimGeminiThinkingSuffix
)
// ParseOpenAIReasoningEffortFromModelSuffix applies the host effort-tail
// whitelist so real model IDs such as qwen-max are not treated as aliases.
// ParseOpenAIReasoningEffortFromModelSuffix applies RelayKit's positive family
// whitelist and the host EffortTailModelIDs escape hatch so real model IDs
// such as gpt-5.1-codex-max remain opaque.
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) {
return kitreasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName, model_setting.ShouldPreserveEffortTail)
}
// ParseLegacyModelSuffix parses the old naked aliases only for positively
// matched GPT/o-series, Claude, and Gemini model families. The provider prefix
// before the final path segment is kept opaque.
func ParseLegacyModelSuffix(modelName string, allowClaudeThinkingAlias bool, allowGeminiThinkingAlias bool) (string, kitreasoning.Intent, bool, error) {
prefix, bare := splitModelNamespace(modelName)
var (
base string
intent kitreasoning.Intent
found bool
err error
)
switch {
case strings.HasPrefix(bare, "claude-"):
base, intent, found, err = kitreasoning.ParseClaudeModelSuffix(bare, allowClaudeThinkingAlias)
case strings.HasPrefix(bare, "gemini-"):
base, intent, found, err = kitreasoning.ParseGeminiModelSuffix(bare, allowGeminiThinkingAlias)
default:
effort, openAIBase := ParseOpenAIReasoningEffortFromModelSuffix(bare)
if effort == "" {
return modelName, kitreasoning.Intent{}, false, nil
}
parsedEffort, parseErr := kitreasoning.ParseEffort(effort)
if parseErr != nil {
return modelName, kitreasoning.Intent{}, false, parseErr
}
mode := kitreasoning.ModeEnabled
if parsedEffort == kitreasoning.EffortNone {
mode = kitreasoning.ModeDisabled
}
base = openAIBase
intent = kitreasoning.Intent{Mode: mode, Effort: parsedEffort, Source: kitreasoning.SourceSuffix}
found = true
}
if err != nil || !found {
return modelName, kitreasoning.Intent{}, false, err
}
return prefix + base, intent, true, nil
}
// BaseModelName strips explicit model modifiers and any enabled legacy alias.
// Names on the thinking-suffix blacklist stay verbatim, including @ tails.
// Malformed legacy aliases stay intact so request conversion can report the
// precise validation error later.
func BaseModelName(modelName string) string {
if model_setting.ShouldPreserveThinkingSuffix(modelName) {
return modelName
}
base := kitreasoning.ParseModelModifiers(modelName).Base
if model_setting.ShouldPreserveThinkingSuffix(base) {
return base
}
legacyBase, _, found, err := ParseLegacyModelSuffix(
base,
model_setting.GetClaudeSettings().ThinkingAdapterEnabled,
model_setting.GetGeminiSettings().ThinkingAdapterEnabled,
)
if err != nil {
return base
}
if found {
return legacyBase
}
return base
}
func splitModelNamespace(modelName string) (string, string) {
if slash := strings.LastIndex(modelName, "/"); slash >= 0 {
return modelName[:slash+1], modelName[slash+1:]
}
return "", modelName
}
// CanonicalBillingModelNames returns specificity-descending canonical billing
// name candidates (without the raw request name or the bare base). Explicit
// @ modifiers and legacy aliases normalize through the same Intent, so order,
// duplicates, and case do not matter. Temperature and topp never appear.
func CanonicalBillingModelNames(modelName string) []string {
if model_setting.ShouldPreserveThinkingSuffix(modelName) {
return nil
}
spec := kitreasoning.ParseModelModifiers(modelName)
base := spec.Base
intent, hasThinking := billingIntentFromModifiers(spec)
if !model_setting.ShouldPreserveThinkingSuffix(base) {
legacyBase, legacyIntent, found, err := ParseLegacyModelSuffix(
base,
model_setting.GetClaudeSettings().ThinkingAdapterEnabled,
model_setting.GetGeminiSettings().ThinkingAdapterEnabled,
)
if err == nil && found {
base = legacyBase
if !hasThinking {
intent = legacyIntent
hasThinking = true
}
}
}
if !hasThinking {
return nil
}
return canonicalNamesFromIntent(base, intent)
}
func billingIntentFromModifiers(spec kitreasoning.ModelModifierSpec) (kitreasoning.Intent, bool) {
last := make(map[string]int, len(spec.Modifiers))
for index, modifier := range spec.Modifiers {
last[modifier.Key] = index
}
var (
intent kitreasoning.Intent
hasThinking bool
)
for index, modifier := range spec.Modifiers {
if last[modifier.Key] != index {
continue
}
switch modifier.Key {
case "thinking":
parsed, ok := kitreasoning.ParseThinkingModifier(modifier.Value)
if !ok {
continue
}
if hasThinking && parsed.Mode != kitreasoning.ModeDisabled && parsed.BudgetTokens == nil {
intent.Mode = parsed.Mode
intent.Source = kitreasoning.SourceSuffix
} else if hasThinking && parsed.Mode != kitreasoning.ModeDisabled {
intent.Mode = parsed.Mode
intent.BudgetTokens = parsed.BudgetTokens
intent.BudgetSource = parsed.BudgetSource
intent.Source = kitreasoning.SourceSuffix
} else {
intent = parsed
}
hasThinking = true
case "effort":
effort, err := kitreasoning.ParseEffort(modifier.Value)
if err != nil || effort == "" {
continue
}
if effort == kitreasoning.EffortNone {
intent = kitreasoning.Intent{Mode: kitreasoning.ModeDisabled, Effort: kitreasoning.EffortNone, Source: kitreasoning.SourceSuffix}
} else {
if intent.Mode == kitreasoning.ModeUnset || intent.Mode == kitreasoning.ModeDisabled {
intent.Mode = kitreasoning.ModeEnabled
}
intent.Effort = effort
intent.Source = kitreasoning.SourceSuffix
}
hasThinking = true
}
}
return intent, hasThinking
}
func canonicalNamesFromIntent(base string, intent kitreasoning.Intent) []string {
thinking, effort, ok := normalizeBillingThinking(intent)
if !ok || base == "" {
return nil
}
var names []string
if effort != "" {
names = append(names, base+"@effort:"+effort+"@thinking:"+thinking)
}
thinkingForm := base + "@thinking:" + thinking
if thinkingForm != base {
names = append(names, thinkingForm)
}
seen := make(map[string]struct{}, len(names))
out := make([]string, 0, len(names))
for _, name := range names {
if name == "" || name == base {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
func normalizeBillingThinking(intent kitreasoning.Intent) (thinking string, effort string, ok bool) {
if intent.Effort == kitreasoning.EffortNone || intent.Mode == kitreasoning.ModeDisabled {
return "off", "", true
}
if intent.BudgetTokens != nil && *intent.BudgetTokens == 0 {
return "off", "", true
}
if intent.Effort != "" && intent.Effort != kitreasoning.EffortNone {
return "on", strings.ToLower(string(intent.Effort)), true
}
if intent.Mode == kitreasoning.ModeEnabled || intent.Mode == kitreasoning.ModeAdaptive || intent.BudgetTokens != nil {
return "on", "", true
}
return "", "", false
}
package reasoning
import (
"testing"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCanonicalBillingModelNames(t *testing.T) {
tests := []struct {
name string
in string
want []string
}{
{
name: "thinking on",
in: "qwen3-max@thinking:on",
want: []string{"qwen3-max@thinking:on"},
},
{
name: "shuffled temperature and thinking",
in: "qwen3-max@temperature:0.2@thinking:on",
want: []string{"qwen3-max@thinking:on"},
},
{
name: "thinking first then temperature",
in: "qwen3-max@thinking:on@temperature:0.2",
want: []string{"qwen3-max@thinking:on"},
},
{
name: "budget normalizes to on",
in: "qwen3-max@thinking:8192",
want: []string{"qwen3-max@thinking:on"},
},
{
name: "minus one normalizes to on",
in: "qwen3-max@thinking:-1",
want: []string{"qwen3-max@thinking:on"},
},
{
name: "adaptive normalizes to on",
in: "qwen3-max@thinking:adaptive",
want: []string{"qwen3-max@thinking:on"},
},
{
name: "thinking off",
in: "qwen3-max@thinking:off",
want: []string{"qwen3-max@thinking:off"},
},
{
name: "effort none becomes thinking off",
in: "qwen3-max@effort:none",
want: []string{"qwen3-max@thinking:off"},
},
{
name: "effort high implies thinking on",
in: "qwen3-max@effort:high",
want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
},
{
name: "effort and thinking keys sorted",
in: "qwen3-max@thinking:on@effort:high@temperature:0.2",
want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
},
{
name: "duplicate last wins then normalize",
in: "qwen3-max@thinking:off@thinking:on@effort:low@effort:high",
want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
},
{
name: "legacy thinking alias",
in: "claude-3-7-sonnet-thinking",
want: []string{"claude-3-7-sonnet@thinking:on"},
},
{
name: "legacy thinking budget matches explicit budget",
in: "gemini-2.5-flash-thinking-8192",
want: []string{"gemini-2.5-flash@thinking:on"},
},
{
name: "legacy nothinking",
in: "claude-3-7-sonnet-nothinking",
want: []string{"claude-3-7-sonnet@thinking:off"},
},
{
name: "temperature only has no reasoning state",
in: "qwen3-max@temperature:0.7",
want: nil,
},
}
geminiSettings := model_setting.GetGeminiSettings()
oldGemini := geminiSettings.ThinkingAdapterEnabled
geminiSettings.ThinkingAdapterEnabled = true
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldGemini })
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, CanonicalBillingModelNames(tt.in))
})
}
assert.Equal(t,
CanonicalBillingModelNames("gemini-2.5-flash@thinking:8192"),
CanonicalBillingModelNames("gemini-2.5-flash-thinking-8192"),
)
assert.Equal(t, "gpt-5.1-codex-max", BaseModelName("gpt-5.1-codex-max"))
assert.Empty(t, CanonicalBillingModelNames("gpt-5.1-codex-max"))
}
func TestParseOpenAIReasoningEffortPreservesCodexMax(t *testing.T) {
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max")
assert.Empty(t, effort)
assert.Equal(t, "gpt-5.1-codex-max", base)
}
func TestBaseModelNameStripsModifiers(t *testing.T) {
require.Equal(t, "qwen3-max", BaseModelName("qwen3-max@thinking:on@temperature:0.2"))
}
func TestExemptAtNameIsOpaqueForBillingIdentity(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
const model = "opaque@sha256:deadbeef"
assert.Equal(t, model, BaseModelName(model))
assert.Empty(t, CanonicalBillingModelNames(model))
assert.Equal(t, "kimi-k2-thinking", BaseModelName("kimi-k2-thinking"))
assert.Empty(t, CanonicalBillingModelNames("kimi-k2-thinking"))
}
......@@ -50,7 +50,7 @@ import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
const thinkingBlacklistExample = JSON.stringify(
['moonshotai/kimi-k2-thinking', 'kimi-k2-thinking'],
['moonshotai/kimi-k2-thinking', 'kimi-k2-thinking', 're:.*@sha256:.*'],
null,
2
)
......@@ -230,7 +230,7 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
</FormControl>
<FormDescription>
{t(
'Models listed here will not automatically append or remove -thinking / -nothinking suffixes.'
'Models listed here skip automatic -thinking / -nothinking suffix handling. Matched names are also exempt from @-modifier parsing and 400 validation. Prefix an entry with re: to match the full model name as a Go regular expression, for example re:.*@sha256:.*'
)}
</FormDescription>
<FormMessage />
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment