498 lines
17 KiB
Go
498 lines
17 KiB
Go
package reasoning
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
)
|
|
|
|
type ClaudeRender struct {
|
|
Thinking *dto.Thinking
|
|
OutputEffort Effort
|
|
EffectiveEffort Effort
|
|
ClearSampling bool
|
|
ConstrainThinkingSampling bool
|
|
// MaxTokens is set when manual thinking needed a larger max_tokens than
|
|
// the request carried; callers apply it to the outgoing request.
|
|
MaxTokens *uint
|
|
Diagnostics []types.ConversionDiagnostic
|
|
}
|
|
|
|
type claudeCapabilities struct {
|
|
adaptive bool
|
|
supportsManual bool
|
|
defaultThinking bool
|
|
supportsDisable bool
|
|
// disableAsBetweenTools marks models whose lowest thinking setting is
|
|
// {"type":"between_tools"} instead of {"type":"disabled"}.
|
|
disableAsBetweenTools bool
|
|
supportsEffort bool
|
|
supportsXHigh bool
|
|
supportsMax bool
|
|
strictSampling bool
|
|
// offEffortAtMostHigh marks models that accept effort high or below only
|
|
// while thinking is off (disabled or between_tools).
|
|
offEffortAtMostHigh bool
|
|
// defaultEffort is the effort the model runs at when a request sets none:
|
|
// medium on Claude Opus 5.5 and Haiku 5.5 and high on every other model
|
|
// (https://platform.claude.com/docs/en/build-with-claude/effort).
|
|
defaultEffort Effort
|
|
}
|
|
|
|
func claudeCapabilitiesFor(model string) claudeCapabilities {
|
|
model = strings.ToLower(model)
|
|
capabilities := claudeCapabilities{supportsManual: true, supportsDisable: true, defaultEffort: EffortHigh}
|
|
|
|
switch {
|
|
case strings.HasPrefix(model, "claude-fable-5"),
|
|
strings.HasPrefix(model, "claude-mythos-5"):
|
|
capabilities.adaptive = true
|
|
capabilities.supportsManual = false
|
|
capabilities.defaultThinking = true
|
|
capabilities.supportsDisable = false
|
|
capabilities.supportsEffort = true
|
|
capabilities.supportsXHigh = true
|
|
capabilities.supportsMax = true
|
|
capabilities.strictSampling = true
|
|
case strings.HasPrefix(model, "claude-mythos-preview"):
|
|
capabilities.adaptive = true
|
|
capabilities.defaultThinking = true
|
|
capabilities.supportsDisable = false
|
|
capabilities.supportsEffort = true
|
|
capabilities.supportsMax = true
|
|
capabilities.strictSampling = true
|
|
case strings.HasPrefix(model, "claude-opus-5-5"),
|
|
strings.HasPrefix(model, "claude-sonnet-5-5"):
|
|
// Opus 5.5 cannot turn thinking off at all; Sonnet 5.5 turns it off
|
|
// with between_tools, which takes effort high or below only.
|
|
capabilities.adaptive = true
|
|
capabilities.supportsManual = false
|
|
capabilities.defaultThinking = true
|
|
capabilities.supportsDisable = false
|
|
capabilities.disableAsBetweenTools = strings.HasPrefix(model, "claude-sonnet-5-5")
|
|
capabilities.offEffortAtMostHigh = capabilities.disableAsBetweenTools
|
|
if strings.HasPrefix(model, "claude-opus-5-5") {
|
|
capabilities.defaultEffort = EffortMedium
|
|
}
|
|
capabilities.supportsEffort = true
|
|
capabilities.supportsXHigh = true
|
|
capabilities.supportsMax = true
|
|
capabilities.strictSampling = true
|
|
case strings.HasPrefix(model, "claude-opus-5"),
|
|
strings.HasPrefix(model, "claude-sonnet-5"),
|
|
strings.HasPrefix(model, "claude-haiku-5"),
|
|
strings.HasPrefix(model, "claude-opus-4-8"),
|
|
strings.HasPrefix(model, "claude-opus-4-7"):
|
|
capabilities.adaptive = true
|
|
capabilities.supportsManual = false
|
|
if strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-sonnet-5") || strings.HasPrefix(model, "claude-haiku-5") {
|
|
capabilities.defaultThinking = true
|
|
}
|
|
// Opus 5 and Haiku 5.5 reject disabled thinking with xhigh or max.
|
|
capabilities.offEffortAtMostHigh = strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-haiku-5")
|
|
if strings.HasPrefix(model, "claude-haiku-5") {
|
|
capabilities.defaultEffort = EffortMedium
|
|
}
|
|
capabilities.supportsEffort = true
|
|
capabilities.supportsXHigh = true
|
|
capabilities.supportsMax = true
|
|
capabilities.strictSampling = true
|
|
case strings.HasPrefix(model, "claude-opus-4-6"),
|
|
strings.HasPrefix(model, "claude-sonnet-4-6"):
|
|
capabilities.adaptive = true
|
|
capabilities.supportsEffort = true
|
|
capabilities.supportsMax = true
|
|
case strings.HasPrefix(model, "claude-opus-4-5"):
|
|
capabilities.supportsEffort = true
|
|
}
|
|
|
|
return capabilities
|
|
}
|
|
|
|
func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPercentage float64) (ClaudeRender, error) {
|
|
disabledWithEffort := intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone
|
|
if disabledWithEffort {
|
|
effort, err := ParseEffort(string(intent.Effort))
|
|
if err != nil {
|
|
return ClaudeRender{}, err
|
|
}
|
|
intent.Effort = effort
|
|
}
|
|
diagnostics := make([]types.ConversionDiagnostic, 0, 1)
|
|
if !disabledWithEffort {
|
|
var normalizeDiagnostics []types.ConversionDiagnostic
|
|
var err error
|
|
intent, normalizeDiagnostics, err = normalizeIntent(intent)
|
|
if err != nil {
|
|
return ClaudeRender{}, err
|
|
}
|
|
diagnostics = append(diagnostics, normalizeDiagnostics...)
|
|
}
|
|
capabilities := claudeCapabilitiesFor(model)
|
|
if !intent.HasStrength() {
|
|
if intent.IncludeThoughts != nil && capabilities.adaptive && capabilities.defaultThinking {
|
|
thinking := &dto.Thinking{Type: "adaptive"}
|
|
if *intent.IncludeThoughts {
|
|
thinking.Display = "summarized"
|
|
} else {
|
|
thinking.Display = "omitted"
|
|
}
|
|
return ClaudeRender{
|
|
Thinking: thinking,
|
|
EffectiveEffort: capabilities.defaultEffort,
|
|
ClearSampling: capabilities.strictSampling,
|
|
}, nil
|
|
}
|
|
if capabilities.defaultThinking {
|
|
return ClaudeRender{EffectiveEffort: capabilities.defaultEffort, ClearSampling: capabilities.strictSampling}, nil
|
|
}
|
|
return ClaudeRender{ClearSampling: capabilities.strictSampling}, nil
|
|
}
|
|
|
|
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
|
// Effort works without thinking, so a requested level is sent at one
|
|
// the model accepts while thinking is off.
|
|
outputEffort := Effort("")
|
|
if disabledWithEffort && (capabilities.supportsDisable || capabilities.disableAsBetweenTools) {
|
|
outputEffort = ClaudeEffort(model, intent.Effort)
|
|
if capabilities.offEffortAtMostHigh && (outputEffort == EffortXHigh || outputEffort == EffortMax) {
|
|
outputEffort = EffortHigh
|
|
}
|
|
diagnostics = append(diagnostics, claudeEffortDiagnostics(model, intent.Effort, outputEffort)...)
|
|
}
|
|
if capabilities.disableAsBetweenTools {
|
|
// between_tools takes no other thinking field and runs at the
|
|
// default effort or the requested one, both within its "high or
|
|
// below" limit.
|
|
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
|
|
"claude_thinking_disable_as_between_tools",
|
|
fmt.Sprintf("model %q rejects disabled thinking; using between_tools, its lowest thinking setting", model),
|
|
))
|
|
return ClaudeRender{
|
|
Thinking: &dto.Thinking{Type: "between_tools"},
|
|
OutputEffort: outputEffort,
|
|
EffectiveEffort: EffortNone,
|
|
ClearSampling: capabilities.strictSampling,
|
|
Diagnostics: diagnostics,
|
|
}, nil
|
|
}
|
|
if !capabilities.supportsDisable {
|
|
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"
|
|
}
|
|
}
|
|
effectiveEffort := capabilities.defaultEffort
|
|
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"},
|
|
OutputEffort: outputEffort,
|
|
EffectiveEffort: EffortNone,
|
|
ClearSampling: capabilities.strictSampling,
|
|
Diagnostics: diagnostics,
|
|
}, nil
|
|
}
|
|
|
|
preferManual := capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
|
|
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
|
|
if effort == "" && intent.BudgetTokens != nil {
|
|
effort = EffortFromBudget(*intent.BudgetTokens)
|
|
}
|
|
if effort == "" && intent.Mode == ModeEnabled {
|
|
effort = capabilities.defaultEffort
|
|
}
|
|
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 = capabilities.defaultEffort
|
|
}
|
|
|
|
// Claude effort can be used without enabling thinking. Preserve that
|
|
// distinction for native Claude requests; OpenAI extractors explicitly
|
|
// mark reasoning efforts as ModeEnabled.
|
|
if intent.Mode == ModeUnset {
|
|
return ClaudeRender{
|
|
OutputEffort: effort,
|
|
EffectiveEffort: effectiveEffort,
|
|
ClearSampling: capabilities.strictSampling,
|
|
Diagnostics: diagnostics,
|
|
}, nil
|
|
}
|
|
|
|
thinking := &dto.Thinking{Type: "adaptive"}
|
|
if intent.IncludeThoughts != nil {
|
|
if *intent.IncludeThoughts {
|
|
thinking.Display = "summarized"
|
|
} else {
|
|
thinking.Display = "omitted"
|
|
}
|
|
}
|
|
return ClaudeRender{
|
|
Thinking: thinking,
|
|
OutputEffort: effort,
|
|
EffectiveEffort: effectiveEffort,
|
|
ClearSampling: capabilities.strictSampling,
|
|
ConstrainThinkingSampling: !capabilities.strictSampling,
|
|
Diagnostics: diagnostics,
|
|
}, nil
|
|
}
|
|
|
|
if intent.Mode == ModeAdaptive {
|
|
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 = capabilities.defaultEffort
|
|
}
|
|
}
|
|
if intent.Mode == ModeUnset {
|
|
// Known models get the effort they accept. They think only when asked,
|
|
// so a removed effort leaves none.
|
|
effort := intent.Effort
|
|
if isKnownClaudeModel(model) {
|
|
effort = ClaudeEffort(model, intent.Effort)
|
|
diagnostics = append(diagnostics, claudeEffortDiagnostics(model, intent.Effort, effort)...)
|
|
}
|
|
effectiveEffort := effort
|
|
if effectiveEffort == "" {
|
|
effectiveEffort = EffortNone
|
|
}
|
|
return ClaudeRender{OutputEffort: effort, EffectiveEffort: effectiveEffort, Diagnostics: diagnostics}, nil
|
|
}
|
|
var raisedMaxTokens *uint
|
|
if maxTokens == nil || *maxTokens <= 1024 {
|
|
// Manual thinking needs room above the 1024-token minimum budget.
|
|
// Raising max_tokens keeps the request usable instead of rejecting it.
|
|
raised := uint(1280)
|
|
if intent.BudgetTokens != nil && *intent.BudgetTokens >= 0 && *intent.BudgetTokens < math.MaxInt32/2 {
|
|
raised = max(raised, uint(*intent.BudgetTokens)+1)
|
|
}
|
|
current := "a missing max_tokens"
|
|
if maxTokens != nil {
|
|
current = fmt.Sprintf("max_tokens %d", *maxTokens)
|
|
}
|
|
diagnostics = append(diagnostics, claudeReasoningDiagnostic(
|
|
"claude_max_tokens_raised",
|
|
fmt.Sprintf("model %q requires max_tokens greater than 1024 for manual thinking; raised %s to %d", model, current, raised),
|
|
))
|
|
raisedMaxTokens = &raised
|
|
maxTokens = &raised
|
|
}
|
|
if uint64(*maxTokens) > uint64(math.MaxInt) {
|
|
return ClaudeRender{}, fmt.Errorf("max_tokens is too large for a thinking budget")
|
|
}
|
|
|
|
budget := 0
|
|
if intent.BudgetTokens != nil && *intent.BudgetTokens >= 0 {
|
|
requestedBudget := *intent.BudgetTokens
|
|
budget = max(requestedBudget, 1024)
|
|
if uint(budget) >= *maxTokens {
|
|
budget = int(*maxTokens) - 1
|
|
}
|
|
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 = max(int(*maxTokens)*percentage/100, 1024)
|
|
if uint(budget) >= *maxTokens {
|
|
budget = int(*maxTokens) - 1
|
|
}
|
|
}
|
|
|
|
effectiveEffort := intent.Effort
|
|
if intent.BudgetTokens != nil && !capabilities.supportsEffort {
|
|
effectiveEffort = EffortFromBudget(budget)
|
|
} else if effectiveEffort == "" {
|
|
effectiveEffort = EffortFromBudget(budget)
|
|
}
|
|
outputEffort := ClaudeEffort(model, intent.Effort)
|
|
if outputEffort != "" {
|
|
effectiveEffort = outputEffort
|
|
}
|
|
thinking := &dto.Thinking{Type: "enabled", BudgetTokens: &budget}
|
|
if intent.IncludeThoughts != nil {
|
|
if *intent.IncludeThoughts {
|
|
thinking.Display = "summarized"
|
|
} else {
|
|
thinking.Display = "omitted"
|
|
}
|
|
}
|
|
return ClaudeRender{
|
|
Thinking: thinking,
|
|
OutputEffort: outputEffort,
|
|
EffectiveEffort: effectiveEffort,
|
|
ConstrainThinkingSampling: true,
|
|
MaxTokens: raisedMaxTokens,
|
|
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,
|
|
}
|
|
}
|
|
|
|
// claudeEffortDiagnostics reports a requested effort that is sent at another
|
|
// level or removed because the model takes none.
|
|
func claudeEffortDiagnostics(model string, requested Effort, sent Effort) []types.ConversionDiagnostic {
|
|
switch {
|
|
case requested == "" || requested == EffortNone || requested == sent:
|
|
return nil
|
|
case sent == "":
|
|
return []types.ConversionDiagnostic{claudeReasoningDiagnostic(
|
|
"claude_effort_unsupported",
|
|
fmt.Sprintf("model %q does not support effort %q; the effort was removed", model, requested),
|
|
)}
|
|
default:
|
|
return []types.ConversionDiagnostic{claudeReasoningDiagnostic(
|
|
"claude_effort_adjusted",
|
|
fmt.Sprintf("model %q does not support effort %q with the selected thinking mode; using %q", model, requested, sent),
|
|
)}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
capabilities := claudeCapabilitiesFor(model)
|
|
return capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
|
|
}
|
|
|
|
func IsKnownClaudeModel(model string) bool {
|
|
return isKnownClaudeModel(model)
|
|
}
|
|
|
|
// ClaudeDefaultEffort is the effort the model runs at when a request sets
|
|
// none.
|
|
func ClaudeDefaultEffort(model string) Effort {
|
|
return claudeCapabilitiesFor(model).defaultEffort
|
|
}
|
|
|
|
// ClaudeEffort returns the effort level the model accepts in place of the
|
|
// requested one. It returns empty when no effort is requested or the model
|
|
// takes none, which includes models missing from the capability table.
|
|
func ClaudeEffort(model string, effort Effort) Effort {
|
|
capabilities := claudeCapabilitiesFor(model)
|
|
if !capabilities.supportsEffort || effort == "" || effort == EffortNone {
|
|
return ""
|
|
}
|
|
return normalizeClaudeEffort(effort, capabilities)
|
|
}
|
|
|
|
// ResolveClaudeDefault makes the Claude model's default thinking explicit for
|
|
// a non-Claude target: no intent becomes adaptive thinking on models that
|
|
// think by default, and thinking without a strength takes the model's
|
|
// default effort.
|
|
func ResolveClaudeDefault(model string, intent Intent) Intent {
|
|
capabilities := claudeCapabilitiesFor(model)
|
|
switch {
|
|
case !intent.HasStrength():
|
|
if !capabilities.defaultThinking {
|
|
return intent
|
|
}
|
|
intent.Mode = ModeAdaptive
|
|
intent.Effort = capabilities.defaultEffort
|
|
case intent.Effort == "" && intent.BudgetTokens == nil && (intent.Mode == ModeAdaptive || intent.Mode == ModeEnabled):
|
|
intent.Effort = capabilities.defaultEffort
|
|
}
|
|
return intent
|
|
}
|
|
|
|
func normalizeClaudeEffort(effort Effort, capabilities claudeCapabilities) Effort {
|
|
switch effort {
|
|
case EffortMinimal:
|
|
return EffortLow
|
|
case EffortXHigh:
|
|
if capabilities.supportsXHigh {
|
|
return effort
|
|
}
|
|
if capabilities.supportsMax {
|
|
return EffortMax
|
|
}
|
|
return EffortHigh
|
|
case EffortMax:
|
|
if !capabilities.supportsMax {
|
|
return EffortHigh
|
|
}
|
|
}
|
|
return effort
|
|
}
|
|
|
|
func effortPercentage(effort Effort, adapterBudgetPercentage float64) int {
|
|
switch effort {
|
|
case EffortMinimal:
|
|
return 5
|
|
case EffortLow:
|
|
return 20
|
|
case EffortMedium:
|
|
return 50
|
|
case EffortHigh:
|
|
return 80
|
|
case EffortXHigh, EffortMax:
|
|
return 95
|
|
}
|
|
percentage := int(math.Round(adapterBudgetPercentage * 100))
|
|
if percentage <= 0 {
|
|
return 80
|
|
}
|
|
if percentage >= 100 {
|
|
return 99
|
|
}
|
|
return percentage
|
|
}
|