Files
CaIon 7c044d7c5c 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 4f6d16e36) 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.
2026-09-04 20:45:52 +08:00

234 lines
7.5 KiB
Go

// Package reasoning re-exports the pure model-name effort-suffix helpers,
// which moved to the conversion kit (relaykit/relayconvert/reasoning) as part
// of the relaykit extraction. Host code keeps importing this path unchanged.
package reasoning
import (
"strings"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/setting/model_setting"
)
var (
EffortSuffixes = kitreasoning.EffortSuffixes
OpenAIEffortSuffixes = kitreasoning.OpenAIEffortSuffixes
DeepSeekV4EffortSuffixes = kitreasoning.DeepSeekV4EffortSuffixes
)
var (
TrimEffortSuffixWithSuffixes = kitreasoning.TrimEffortSuffixWithSuffixes
ParseDeepSeekV4ThinkingSuffix = kitreasoning.ParseDeepSeekV4ThinkingSuffix
TrimGeminiThinkingSuffix = kitreasoning.TrimGeminiThinkingSuffix
)
// 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
}