feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
This commit is contained in:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,366 @@
|
||||
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
|
||||
Diagnostics []types.ConversionDiagnostic
|
||||
}
|
||||
|
||||
type claudeCapabilities struct {
|
||||
adaptive bool
|
||||
supportsManual bool
|
||||
defaultThinking bool
|
||||
supportsDisable bool
|
||||
supportsEffort bool
|
||||
supportsXHigh bool
|
||||
supportsMax bool
|
||||
strictSampling bool
|
||||
}
|
||||
|
||||
func claudeCapabilitiesFor(model string) claudeCapabilities {
|
||||
model = strings.ToLower(model)
|
||||
capabilities := claudeCapabilities{supportsManual: true, supportsDisable: true}
|
||||
|
||||
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.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.supportsMax = true
|
||||
capabilities.strictSampling = true
|
||||
case strings.HasPrefix(model, "claude-opus-5"),
|
||||
strings.HasPrefix(model, "claude-sonnet-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") {
|
||||
capabilities.defaultThinking = true
|
||||
}
|
||||
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
|
||||
} else {
|
||||
var err error
|
||||
intent, err = normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return ClaudeRender{}, err
|
||||
}
|
||||
}
|
||||
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"}
|
||||
if *intent.IncludeThoughts {
|
||||
thinking.Display = "summarized"
|
||||
} else {
|
||||
thinking.Display = "omitted"
|
||||
}
|
||||
return ClaudeRender{
|
||||
Thinking: thinking,
|
||||
EffectiveEffort: EffortHigh,
|
||||
ClearSampling: capabilities.strictSampling,
|
||||
}, nil
|
||||
}
|
||||
if capabilities.defaultThinking {
|
||||
return ClaudeRender{EffectiveEffort: EffortHigh, ClearSampling: capabilities.strictSampling}, nil
|
||||
}
|
||||
return ClaudeRender{ClearSampling: capabilities.strictSampling}, nil
|
||||
}
|
||||
|
||||
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
||||
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"
|
||||
}
|
||||
}
|
||||
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.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 = EffortHigh
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 = EffortHigh
|
||||
}
|
||||
}
|
||||
if intent.Mode == ModeUnset {
|
||||
return ClaudeRender{OutputEffort: intent.Effort, EffectiveEffort: intent.Effort}, nil
|
||||
}
|
||||
if maxTokens == nil {
|
||||
return ClaudeRender{}, fmt.Errorf("max_tokens is required for manual Claude thinking")
|
||||
}
|
||||
if *maxTokens <= 1024 {
|
||||
return ClaudeRender{}, fmt.Errorf("max_tokens must be greater than 1024 for manual Claude thinking")
|
||||
}
|
||||
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 := Effort("")
|
||||
if capabilities.supportsEffort && intent.Effort != "" {
|
||||
outputEffort = normalizeClaudeEffort(intent.Effort, capabilities)
|
||||
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,
|
||||
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 {
|
||||
capabilities := claudeCapabilitiesFor(model)
|
||||
return capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
|
||||
}
|
||||
|
||||
func IsKnownClaudeModel(model string) bool {
|
||||
return isKnownClaudeModel(model)
|
||||
}
|
||||
|
||||
func ResolveClaudeDefault(model string, intent Intent) Intent {
|
||||
if intent.HasStrength() || !claudeCapabilitiesFor(model).defaultThinking {
|
||||
return intent
|
||||
}
|
||||
intent.Mode = ModeAdaptive
|
||||
intent.Effort = EffortHigh
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
)
|
||||
|
||||
type GeminiRender struct {
|
||||
Config *dto.GeminiThinkingConfig
|
||||
EffectiveEffort Effort
|
||||
}
|
||||
|
||||
type geminiThinkingKind int
|
||||
|
||||
const (
|
||||
geminiThinkingUnknown geminiThinkingKind = iota
|
||||
geminiThinkingNotConfigurable
|
||||
geminiThinkingBudget
|
||||
geminiThinkingLevel
|
||||
)
|
||||
|
||||
type geminiCapabilities struct {
|
||||
kind geminiThinkingKind
|
||||
supportsDisable bool
|
||||
supportsIncludeThoughts bool
|
||||
minBudget int
|
||||
maxBudget int
|
||||
}
|
||||
|
||||
func geminiCapabilitiesFor(model string) geminiCapabilities {
|
||||
model = strings.ToLower(model)
|
||||
switch {
|
||||
case strings.HasPrefix(model, "gemini-2.5-flash-native-audio"),
|
||||
strings.HasPrefix(model, "gemini-live-2.5-flash-preview-native-audio"):
|
||||
return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, maxBudget: 24576}
|
||||
case strings.HasPrefix(model, "gemini-2.5-flash-image"),
|
||||
strings.Contains(model, "-tts"),
|
||||
strings.Contains(model, "-native-audio"),
|
||||
strings.Contains(model, "-live"):
|
||||
return geminiCapabilities{kind: geminiThinkingNotConfigurable}
|
||||
case strings.HasPrefix(model, "gemini-3-pro-image"),
|
||||
strings.HasPrefix(model, "nano-banana-pro"):
|
||||
return geminiCapabilities{kind: geminiThinkingNotConfigurable, supportsIncludeThoughts: true}
|
||||
case model == "gemini-flash-latest", model == "gemini-flash-lite-latest":
|
||||
return geminiCapabilities{kind: geminiThinkingLevel}
|
||||
case model == "gemini-pro-latest":
|
||||
return geminiCapabilities{kind: geminiThinkingLevel}
|
||||
case strings.HasPrefix(model, "gemini-2.5-pro"):
|
||||
return geminiCapabilities{kind: geminiThinkingBudget, minBudget: 128, maxBudget: 32768}
|
||||
case strings.HasPrefix(model, "gemini-2.5-flash-lite"):
|
||||
return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, minBudget: 512, maxBudget: 24576}
|
||||
case strings.HasPrefix(model, "gemini-2.5-"):
|
||||
return geminiCapabilities{kind: geminiThinkingBudget, supportsDisable: true, maxBudget: 24576}
|
||||
case strings.HasPrefix(model, "gemini-3"):
|
||||
return geminiCapabilities{kind: geminiThinkingLevel}
|
||||
default:
|
||||
return geminiCapabilities{}
|
||||
}
|
||||
}
|
||||
|
||||
func RenderGemini(model string, intent Intent, maxOutputTokens *uint, adapterBudgetPercentage float64) (GeminiRender, error) {
|
||||
intent, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return GeminiRender{}, err
|
||||
}
|
||||
if intent.IsEmpty() {
|
||||
return GeminiRender{}, nil
|
||||
}
|
||||
|
||||
capabilities := geminiCapabilitiesFor(model)
|
||||
if capabilities.kind == geminiThinkingNotConfigurable {
|
||||
if !intent.HasStrength() && capabilities.supportsIncludeThoughts {
|
||||
return GeminiRender{Config: &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}, EffectiveEffort: EffortHigh}, nil
|
||||
}
|
||||
return GeminiRender{}, fmt.Errorf("model %q does not support configurable thinking", model)
|
||||
}
|
||||
if capabilities.kind == geminiThinkingUnknown {
|
||||
if intent.HasStrength() {
|
||||
return GeminiRender{}, fmt.Errorf("model %q does not have a known Gemini thinking configuration", model)
|
||||
}
|
||||
return GeminiRender{Config: &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}}, nil
|
||||
}
|
||||
|
||||
config := &dto.GeminiThinkingConfig{IncludeThoughts: intent.IncludeThoughts}
|
||||
if capabilities.kind == geminiThinkingBudget {
|
||||
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
||||
if !capabilities.supportsDisable {
|
||||
return GeminiRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
|
||||
}
|
||||
budget := 0
|
||||
config.ThinkingBudget = &budget
|
||||
return GeminiRender{Config: config, EffectiveEffort: EffortNone}, nil
|
||||
}
|
||||
|
||||
budget := 0
|
||||
hasBudget := false
|
||||
if intent.BudgetTokens != nil {
|
||||
budget = *intent.BudgetTokens
|
||||
if intent.BudgetSource != SourceNative && budget != -1 {
|
||||
budget = clampGeminiBudget(budget, capabilities)
|
||||
}
|
||||
hasBudget = true
|
||||
} else if intent.Effort != "" {
|
||||
budget = gemini25BudgetForEffort(intent.Effort)
|
||||
hasBudget = true
|
||||
} else if intent.Mode != ModeUnset && maxOutputTokens != nil && *maxOutputTokens > 0 {
|
||||
if uint64(*maxOutputTokens) > uint64(math.MaxInt) {
|
||||
return GeminiRender{}, fmt.Errorf("max_output_tokens is too large for a thinking budget")
|
||||
}
|
||||
percentage := adapterBudgetPercentage
|
||||
if percentage <= 0 {
|
||||
percentage = 0.6
|
||||
} else if percentage > 1 {
|
||||
percentage = 1
|
||||
}
|
||||
budget = int(math.Round(float64(*maxOutputTokens) * percentage))
|
||||
budget = clampGeminiBudget(budget, capabilities)
|
||||
hasBudget = true
|
||||
}
|
||||
if hasBudget {
|
||||
if err := validateGeminiBudget(model, budget, capabilities); err != nil {
|
||||
return GeminiRender{}, err
|
||||
}
|
||||
config.ThinkingBudget = &budget
|
||||
}
|
||||
effort := intent.Effort
|
||||
if hasBudget {
|
||||
effort = EffortFromBudget(budget)
|
||||
} else if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
||||
effort = geminiDefaultEffort(model)
|
||||
}
|
||||
return GeminiRender{Config: config, EffectiveEffort: effort}, nil
|
||||
}
|
||||
|
||||
if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
||||
return GeminiRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
|
||||
}
|
||||
effort := intent.Effort
|
||||
if effort == "" && intent.BudgetTokens != nil {
|
||||
effort = EffortFromBudget(*intent.BudgetTokens)
|
||||
}
|
||||
if effort != "" {
|
||||
level, err := geminiLevelForEffort(model, effort)
|
||||
if err != nil {
|
||||
return GeminiRender{}, err
|
||||
}
|
||||
config.ThinkingLevel = level
|
||||
effort = Effort(level)
|
||||
} else if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
||||
effort = geminiDefaultEffort(model)
|
||||
}
|
||||
return GeminiRender{Config: config, EffectiveEffort: effort}, nil
|
||||
}
|
||||
|
||||
func geminiDefaultEffort(model string) Effort {
|
||||
model = strings.ToLower(model)
|
||||
switch {
|
||||
case model == "gemini-flash-latest",
|
||||
strings.HasPrefix(model, "gemini-3.5-flash") && !strings.HasPrefix(model, "gemini-3.5-flash-lite"),
|
||||
strings.HasPrefix(model, "gemini-3.6-flash"):
|
||||
return EffortMedium
|
||||
case model == "gemini-flash-lite-latest",
|
||||
strings.HasPrefix(model, "gemini-3.5-flash-lite"),
|
||||
strings.HasPrefix(model, "gemini-3.1-flash-lite"):
|
||||
return EffortMinimal
|
||||
case model == "gemini-pro-latest",
|
||||
strings.HasPrefix(model, "gemini-3.1-pro"),
|
||||
strings.HasPrefix(model, "gemini-3-pro"),
|
||||
strings.HasPrefix(model, "gemini-3-flash"):
|
||||
return EffortHigh
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateGeminiThinkingConfig(model string, config *dto.GeminiThinkingConfig) (Effort, error) {
|
||||
if config == nil {
|
||||
return "", nil
|
||||
}
|
||||
intent, err := FromGemini(&dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{ThinkingConfig: config}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
capabilities := geminiCapabilitiesFor(model)
|
||||
if capabilities.kind == geminiThinkingNotConfigurable {
|
||||
if !intent.HasStrength() && capabilities.supportsIncludeThoughts {
|
||||
return EffortHigh, nil
|
||||
}
|
||||
return "", fmt.Errorf("model %q does not support configurable thinking", model)
|
||||
}
|
||||
if capabilities.kind == geminiThinkingUnknown {
|
||||
return EffectiveEffort(intent), nil
|
||||
}
|
||||
if capabilities.kind == geminiThinkingBudget {
|
||||
if config.ThinkingLevel != "" {
|
||||
return "", fmt.Errorf("Gemini 2.5 model %q requires thinkingBudget, not thinkingLevel", model)
|
||||
}
|
||||
if config.ThinkingBudget != nil {
|
||||
if err := validateGeminiBudget(model, *config.ThinkingBudget, capabilities); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return EffectiveEffort(intent), nil
|
||||
}
|
||||
if config.ThinkingBudget != nil {
|
||||
return "", fmt.Errorf("Gemini 3 model %q requires thinkingLevel, not thinkingBudget", model)
|
||||
}
|
||||
if config.ThinkingLevel != "" {
|
||||
level, err := geminiLevelForEffort(model, intent.Effort)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if level != config.ThinkingLevel {
|
||||
return "", fmt.Errorf("thinkingLevel %q is not supported by model %q", config.ThinkingLevel, model)
|
||||
}
|
||||
return Effort(level), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ResolveGeminiDefault materializes documented family defaults when a
|
||||
// conversion targets another protocol. Dynamic 2.5 defaults retain their -1
|
||||
// budget in the in-process pivot; Flash-Lite's default is explicitly off.
|
||||
func ResolveGeminiDefault(model string, intent Intent) Intent {
|
||||
if intent.HasStrength() {
|
||||
return intent
|
||||
}
|
||||
capabilities := geminiCapabilitiesFor(model)
|
||||
if capabilities.kind == geminiThinkingBudget {
|
||||
if strings.HasPrefix(strings.ToLower(model), "gemini-2.5-flash-lite") {
|
||||
budget := 0
|
||||
intent.Mode = ModeDisabled
|
||||
intent.Effort = EffortNone
|
||||
intent.BudgetTokens = &budget
|
||||
intent.BudgetSource = SourceNative
|
||||
return intent
|
||||
}
|
||||
budget := -1
|
||||
intent.Mode = ModeEnabled
|
||||
intent.BudgetTokens = &budget
|
||||
intent.BudgetSource = SourceNative
|
||||
return intent
|
||||
}
|
||||
if capabilities.kind != geminiThinkingLevel {
|
||||
return intent
|
||||
}
|
||||
effort := geminiDefaultEffort(model)
|
||||
if effort == "" {
|
||||
return intent
|
||||
}
|
||||
intent.Mode = ModeEnabled
|
||||
intent.Effort = effort
|
||||
return intent
|
||||
}
|
||||
|
||||
// ResolveGeminiEnabledDefault fills the strength implied by an explicit
|
||||
// enable-only control such as the legacy -thinking model alias.
|
||||
func ResolveGeminiEnabledDefault(model string, intent Intent, maxOutputTokens *uint) Intent {
|
||||
if intent.Mode != ModeEnabled || intent.Effort != "" || intent.BudgetTokens != nil {
|
||||
return intent
|
||||
}
|
||||
capabilities := geminiCapabilitiesFor(model)
|
||||
if capabilities.kind == geminiThinkingBudget {
|
||||
if intent.Source == SourceSuffix && maxOutputTokens != nil && *maxOutputTokens > 0 {
|
||||
return intent
|
||||
}
|
||||
budget := -1
|
||||
intent.BudgetTokens = &budget
|
||||
intent.BudgetSource = SourceSuffix
|
||||
return intent
|
||||
}
|
||||
if capabilities.kind == geminiThinkingLevel {
|
||||
intent.Effort = geminiDefaultEffort(model)
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
// EquivalentGeminiStrength compares two controls after applying the target
|
||||
// model's budget/level mapping. This accepts distinct canonical labels that
|
||||
// are identical on the Gemini wire (for example minimal and low on 2.5).
|
||||
func EquivalentGeminiStrength(model string, left Intent, right Intent) (bool, error) {
|
||||
leftRendered, err := RenderGemini(model, left, nil, 0)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rightRendered, err := RenderGemini(model, right, nil, 0)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if leftRendered.Config == nil || rightRendered.Config == nil {
|
||||
return leftRendered.Config == nil && rightRendered.Config == nil, nil
|
||||
}
|
||||
leftConfig, rightConfig := leftRendered.Config, rightRendered.Config
|
||||
if leftConfig.ThinkingLevel != rightConfig.ThinkingLevel {
|
||||
return false, nil
|
||||
}
|
||||
if (leftConfig.ThinkingBudget == nil) != (rightConfig.ThinkingBudget == nil) {
|
||||
return false, nil
|
||||
}
|
||||
return leftConfig.ThinkingBudget == nil || *leftConfig.ThinkingBudget == *rightConfig.ThinkingBudget, nil
|
||||
}
|
||||
|
||||
func gemini25BudgetForEffort(effort Effort) int {
|
||||
switch effort {
|
||||
case EffortMinimal, EffortLow:
|
||||
return 1024
|
||||
case EffortMedium:
|
||||
return 8192
|
||||
case EffortHigh, EffortXHigh, EffortMax:
|
||||
return 24576
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func geminiLevelForEffort(model string, effort Effort) (string, error) {
|
||||
model = strings.ToLower(model)
|
||||
switch {
|
||||
case strings.HasPrefix(model, "gemini-3.1-flash-image"),
|
||||
strings.HasPrefix(model, "gemini-3.1-flash-lite-image"):
|
||||
if effort == EffortMinimal || effort == EffortLow {
|
||||
return string(EffortMinimal), nil
|
||||
}
|
||||
return string(EffortHigh), nil
|
||||
case (strings.HasPrefix(model, "gemini-3-pro") && !strings.HasPrefix(model, "gemini-3.1-pro")):
|
||||
if effort == EffortMinimal || effort == EffortLow {
|
||||
return string(EffortLow), nil
|
||||
}
|
||||
return string(EffortHigh), nil
|
||||
case strings.HasPrefix(model, "gemini-3.1-pro"), model == "gemini-pro-latest":
|
||||
if effort == EffortMinimal {
|
||||
return string(EffortLow), nil
|
||||
}
|
||||
}
|
||||
switch effort {
|
||||
case EffortMinimal, EffortLow, EffortMedium, EffortHigh:
|
||||
return string(effort), nil
|
||||
case EffortXHigh, EffortMax:
|
||||
return string(EffortHigh), nil
|
||||
case EffortNone:
|
||||
return "", fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
|
||||
default:
|
||||
return "", fmt.Errorf("%w %q for model %q", ErrUnsupportedEffort, effort, model)
|
||||
}
|
||||
}
|
||||
|
||||
func validateGeminiBudget(model string, budget int, capabilities geminiCapabilities) error {
|
||||
if budget == -1 {
|
||||
return nil
|
||||
}
|
||||
if budget == 0 {
|
||||
if capabilities.supportsDisable {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
|
||||
}
|
||||
if budget < capabilities.minBudget || budget > capabilities.maxBudget {
|
||||
return fmt.Errorf("thinking budget %d is outside the supported range [%d,%d] for model %q", budget, capabilities.minBudget, capabilities.maxBudget, model)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampGeminiBudget(budget int, capabilities geminiCapabilities) int {
|
||||
if budget < capabilities.minBudget {
|
||||
return capabilities.minBudget
|
||||
}
|
||||
if budget > capabilities.maxBudget {
|
||||
return capabilities.maxBudget
|
||||
}
|
||||
return budget
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
type Effort string
|
||||
|
||||
const (
|
||||
EffortNone Effort = "none"
|
||||
EffortMinimal Effort = "minimal"
|
||||
EffortLow Effort = "low"
|
||||
EffortMedium Effort = "medium"
|
||||
EffortHigh Effort = "high"
|
||||
EffortXHigh Effort = "xhigh"
|
||||
EffortMax Effort = "max"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
type Source string
|
||||
|
||||
// ClientError marks invalid user-supplied reasoning controls so host handlers
|
||||
// can return a 4xx without classifying unrelated adapter failures as client
|
||||
// errors.
|
||||
type ClientError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *ClientError) Error() string { return e.err.Error() }
|
||||
func (e *ClientError) Unwrap() error { return e.err }
|
||||
|
||||
func AsClientError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var clientErr *ClientError
|
||||
if errors.As(err, &clientErr) {
|
||||
return err
|
||||
}
|
||||
return &ClientError{err: err}
|
||||
}
|
||||
|
||||
func IsClientError(err error) bool {
|
||||
var clientErr *ClientError
|
||||
return errors.As(err, &clientErr)
|
||||
}
|
||||
|
||||
const (
|
||||
ModeUnset Mode = ""
|
||||
ModeEnabled Mode = "enabled"
|
||||
ModeAdaptive Mode = "adaptive"
|
||||
ModeDisabled Mode = "disabled"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceExplicit Source = "explicit"
|
||||
SourceNative Source = "native"
|
||||
SourceSuffix Source = "suffix"
|
||||
SourcePivot Source = "pivot"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEffortConflict = errors.New("reasoning settings conflict")
|
||||
ErrUnsupportedEffort = errors.New("unsupported reasoning effort")
|
||||
ErrThinkingNotDisabled = errors.New("thinking cannot be disabled")
|
||||
)
|
||||
|
||||
// Intent is the protocol-independent part of a request's reasoning controls.
|
||||
// Summary visibility is intentionally independent from reasoning strength.
|
||||
type Intent struct {
|
||||
Mode Mode
|
||||
Effort Effort
|
||||
BudgetTokens *int
|
||||
IncludeThoughts *bool
|
||||
Source Source
|
||||
BudgetSource Source
|
||||
}
|
||||
|
||||
func (i Intent) HasStrength() bool {
|
||||
return i.Mode != ModeUnset || i.Effort != "" || i.BudgetTokens != nil
|
||||
}
|
||||
|
||||
func (i Intent) IsEmpty() bool {
|
||||
return !i.HasStrength() && i.IncludeThoughts == nil
|
||||
}
|
||||
|
||||
// IntentFromState reconstructs a portable intent from host- or pivot-carried
|
||||
// conversion state. A nil state is an empty intent.
|
||||
func IntentFromState(state *dto.ReasoningConversionState) Intent {
|
||||
if state == nil {
|
||||
return Intent{}
|
||||
}
|
||||
return Intent{
|
||||
Mode: Mode(state.Mode),
|
||||
Effort: Effort(state.Effort),
|
||||
BudgetTokens: state.BudgetTokens,
|
||||
IncludeThoughts: state.IncludeThoughts,
|
||||
Source: SourceSuffix,
|
||||
BudgetSource: SourceSuffix,
|
||||
}
|
||||
}
|
||||
|
||||
// StateFromIntent copies the portable fields of an intent into conversion
|
||||
// state. Empty intents produce nil so callers can omit the field.
|
||||
func StateFromIntent(intent Intent) *dto.ReasoningConversionState {
|
||||
if intent.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
return &dto.ReasoningConversionState{
|
||||
Mode: string(intent.Mode),
|
||||
Effort: string(intent.Effort),
|
||||
BudgetTokens: intent.BudgetTokens,
|
||||
IncludeThoughts: intent.IncludeThoughts,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseEffort(value string) (Effort, error) {
|
||||
effort := Effort(strings.ToLower(strings.TrimSpace(value)))
|
||||
if effort == "" {
|
||||
return "", nil
|
||||
}
|
||||
switch effort {
|
||||
case EffortNone, EffortMinimal, EffortLow, EffortMedium, EffortHigh, EffortXHigh, EffortMax:
|
||||
return effort, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %q", ErrUnsupportedEffort, value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeIntent(intent Intent) (Intent, error) {
|
||||
effort, err := ParseEffort(string(intent.Effort))
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
|
||||
switch intent.Mode {
|
||||
case ModeUnset, ModeEnabled, ModeAdaptive, ModeDisabled:
|
||||
default:
|
||||
return Intent{}, fmt.Errorf("unsupported reasoning mode %q", intent.Mode)
|
||||
}
|
||||
|
||||
if intent.BudgetTokens != nil {
|
||||
budget := *intent.BudgetTokens
|
||||
if budget < -1 {
|
||||
return Intent{}, fmt.Errorf("thinking budget must be -1 or non-negative, got %d", budget)
|
||||
}
|
||||
if budget == 0 {
|
||||
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive || (intent.Effort != "" && intent.Effort != EffortNone) {
|
||||
return Intent{}, fmt.Errorf("%w: zero budget disables thinking", ErrEffortConflict)
|
||||
}
|
||||
intent.Mode = ModeDisabled
|
||||
intent.Effort = EffortNone
|
||||
} else if intent.Mode == ModeDisabled || intent.Effort == EffortNone {
|
||||
return Intent{}, fmt.Errorf("%w: a non-zero budget enables thinking", ErrEffortConflict)
|
||||
} else if intent.Mode == ModeUnset {
|
||||
intent.Mode = ModeEnabled
|
||||
}
|
||||
}
|
||||
|
||||
if intent.Effort == EffortNone {
|
||||
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
||||
return Intent{}, fmt.Errorf("%w: effort none disables thinking", ErrEffortConflict)
|
||||
}
|
||||
intent.Mode = ModeDisabled
|
||||
}
|
||||
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
// MergeExplicitAndSuffix combines structured request fields with a model-name
|
||||
// alias. Contradictions are rejected because the alias may carry a distinct
|
||||
// billing identity; silently choosing either side would make request semantics
|
||||
// and accounting disagree.
|
||||
func MergeExplicitAndSuffix(explicit Intent, suffix Intent, model string) (Intent, error) {
|
||||
var err error
|
||||
explicit, err = normalizeIntent(explicit)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
suffix, err = normalizeIntent(suffix)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
if !explicit.HasStrength() {
|
||||
if explicit.IncludeThoughts != nil {
|
||||
suffix.IncludeThoughts = explicit.IncludeThoughts
|
||||
}
|
||||
return suffix, nil
|
||||
}
|
||||
if !suffix.HasStrength() {
|
||||
if explicit.IncludeThoughts == nil {
|
||||
explicit.IncludeThoughts = suffix.IncludeThoughts
|
||||
}
|
||||
return explicit, nil
|
||||
}
|
||||
|
||||
explicitDisabled := explicit.Mode == ModeDisabled || explicit.Effort == EffortNone
|
||||
suffixDisabled := suffix.Mode == ModeDisabled || suffix.Effort == EffortNone
|
||||
if explicitDisabled != suffixDisabled {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit fields and model suffix disagree about whether thinking is enabled", ErrEffortConflict, model)
|
||||
}
|
||||
if !explicitDisabled && explicit.Effort != "" && suffix.Effort != "" && explicit.Effort != suffix.Effort {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit effort %q differs from suffix effort %q", ErrEffortConflict, model, explicit.Effort, suffix.Effort)
|
||||
}
|
||||
if explicit.BudgetTokens != nil && suffix.BudgetTokens != nil && *explicit.BudgetTokens != *suffix.BudgetTokens {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit budget %d differs from suffix budget %d", ErrEffortConflict, model, *explicit.BudgetTokens, *suffix.BudgetTokens)
|
||||
}
|
||||
if (explicit.Effort != "" && explicit.Effort != EffortNone && suffix.BudgetTokens != nil) ||
|
||||
(explicit.BudgetTokens != nil && suffix.Effort != "" && suffix.Effort != EffortNone) {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: effort and an exact suffix budget cannot both select reasoning strength", ErrEffortConflict, model)
|
||||
}
|
||||
|
||||
merged := suffix
|
||||
if explicit.Mode != ModeUnset {
|
||||
merged.Mode = explicit.Mode
|
||||
}
|
||||
if explicit.Effort != "" {
|
||||
merged.Effort = explicit.Effort
|
||||
}
|
||||
if explicit.BudgetTokens != nil {
|
||||
merged.BudgetTokens = explicit.BudgetTokens
|
||||
merged.BudgetSource = explicit.BudgetSource
|
||||
}
|
||||
if explicit.IncludeThoughts != nil {
|
||||
merged.IncludeThoughts = explicit.IncludeThoughts
|
||||
}
|
||||
return normalizeIntent(merged)
|
||||
}
|
||||
|
||||
// MergeExplicit combines two structured representations of the same request.
|
||||
// A numeric budget and an effort may coexist: Claude and OpenRouter expose both
|
||||
// controls, and keeping both is what lets an in-memory OpenAI pivot preserve an
|
||||
// exact budget for budget-based targets while retaining an effort for
|
||||
// level-based targets.
|
||||
func MergeExplicit(primary Intent, secondary Intent, model string) (Intent, error) {
|
||||
var err error
|
||||
primary, err = normalizeIntent(primary)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
secondary, err = normalizeIntent(secondary)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
|
||||
if primary.IsEmpty() {
|
||||
return secondary, nil
|
||||
}
|
||||
if secondary.IsEmpty() {
|
||||
return primary, nil
|
||||
}
|
||||
|
||||
primaryDisabled := primary.Mode == ModeDisabled || primary.Effort == EffortNone
|
||||
secondaryDisabled := secondary.Mode == ModeDisabled || secondary.Effort == EffortNone
|
||||
if primary.HasStrength() && secondary.HasStrength() && primaryDisabled != secondaryDisabled {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit fields disagree about whether thinking is enabled", ErrEffortConflict, model)
|
||||
}
|
||||
if primary.Effort != "" && secondary.Effort != "" && primary.Effort != secondary.Effort {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit efforts %q and %q differ", ErrEffortConflict, model, primary.Effort, secondary.Effort)
|
||||
}
|
||||
if primary.BudgetTokens != nil && secondary.BudgetTokens != nil && *primary.BudgetTokens != *secondary.BudgetTokens {
|
||||
return Intent{}, fmt.Errorf("%w for model %q: explicit budgets %d and %d differ", ErrEffortConflict, model, *primary.BudgetTokens, *secondary.BudgetTokens)
|
||||
}
|
||||
|
||||
merged := secondary
|
||||
if primary.Mode != ModeUnset {
|
||||
merged.Mode = primary.Mode
|
||||
}
|
||||
if primary.Effort != "" {
|
||||
merged.Effort = primary.Effort
|
||||
}
|
||||
if primary.BudgetTokens != nil {
|
||||
merged.BudgetTokens = primary.BudgetTokens
|
||||
merged.BudgetSource = primary.BudgetSource
|
||||
}
|
||||
if primary.IncludeThoughts != nil {
|
||||
merged.IncludeThoughts = primary.IncludeThoughts
|
||||
}
|
||||
return normalizeIntent(merged)
|
||||
}
|
||||
|
||||
type openRouterReasoning struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Effort string `json:"effort,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
Exclude *bool `json:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
func FromOpenAIChat(req *dto.GeneralOpenAIRequest) (Intent, error) {
|
||||
if req == nil {
|
||||
return Intent{}, nil
|
||||
}
|
||||
|
||||
var intent Intent
|
||||
intent.Source = SourceExplicit
|
||||
if req.ReasoningEffort != "" {
|
||||
effort, err := ParseEffort(req.ReasoningEffort)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
if effort == EffortNone {
|
||||
intent.Mode = ModeDisabled
|
||||
} else {
|
||||
intent.Mode = ModeEnabled
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.Reasoning) > 0 {
|
||||
var raw openRouterReasoning
|
||||
if err := kitutil.Unmarshal(req.Reasoning, &raw); err != nil {
|
||||
return Intent{}, fmt.Errorf("invalid reasoning config: %w", err)
|
||||
}
|
||||
nested := Intent{BudgetTokens: raw.MaxTokens, Source: SourceExplicit, BudgetSource: SourceExplicit}
|
||||
if raw.Enabled != nil {
|
||||
if *raw.Enabled {
|
||||
nested.Mode = ModeEnabled
|
||||
} else {
|
||||
nested.Mode = ModeDisabled
|
||||
nested.Effort = EffortNone
|
||||
}
|
||||
}
|
||||
if raw.Effort != "" {
|
||||
effort, err := ParseEffort(raw.Effort)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
nested.Effort = effort
|
||||
if effort == EffortNone {
|
||||
nested.Mode = ModeDisabled
|
||||
} else if nested.Mode == ModeUnset {
|
||||
nested.Mode = ModeEnabled
|
||||
}
|
||||
}
|
||||
if raw.Exclude != nil {
|
||||
include := !*raw.Exclude
|
||||
nested.IncludeThoughts = &include
|
||||
}
|
||||
var err error
|
||||
intent, err = MergeExplicit(intent, nested, req.Model)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if req.ReasoningConversion == nil {
|
||||
return normalizeIntent(intent)
|
||||
}
|
||||
pivot := Intent{
|
||||
Mode: Mode(req.ReasoningConversion.Mode),
|
||||
Effort: Effort(req.ReasoningConversion.Effort),
|
||||
BudgetTokens: req.ReasoningConversion.BudgetTokens,
|
||||
IncludeThoughts: req.ReasoningConversion.IncludeThoughts,
|
||||
Source: SourcePivot,
|
||||
BudgetSource: SourcePivot,
|
||||
}
|
||||
if req.ReasoningEffort != "" {
|
||||
pivotEffort := EffectiveEffort(pivot)
|
||||
if Effort(req.ReasoningEffort) == pivotEffort {
|
||||
intent.Effort = ""
|
||||
intent.Mode = ModeUnset
|
||||
}
|
||||
}
|
||||
return MergeExplicit(intent, pivot, req.Model)
|
||||
}
|
||||
|
||||
// ApplyToOpenAIChat writes the portable portion of an intent to the OpenAI
|
||||
// pivot. reasoning_effort carries level-based strength; a JSON-excluded DTO
|
||||
// state retains exact budgets and summary visibility across in-process steps.
|
||||
func ApplyToOpenAIChat(req *dto.GeneralOpenAIRequest, intent Intent) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
intent, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if effort := EffectiveEffort(intent); effort != "" {
|
||||
req.ReasoningEffort = string(effort)
|
||||
}
|
||||
|
||||
if intent.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
req.ReasoningConversion = &dto.ReasoningConversionState{
|
||||
Mode: string(intent.Mode),
|
||||
Effort: string(intent.Effort),
|
||||
BudgetTokens: intent.BudgetTokens,
|
||||
IncludeThoughts: intent.IncludeThoughts,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyToOpenAIResponses writes the portable portion of an intent directly to
|
||||
// a Responses request. The JSON-excluded state retains exact provider-native
|
||||
// controls for any later in-process conversion.
|
||||
func ApplyToOpenAIResponses(req *dto.OpenAIResponsesRequest, intent Intent) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
intent, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if effort := EffectiveEffort(intent); effort != "" {
|
||||
summary := "detailed"
|
||||
if effort == EffortNone || (intent.IncludeThoughts != nil && !*intent.IncludeThoughts) {
|
||||
summary = ""
|
||||
}
|
||||
req.Reasoning = &dto.Reasoning{
|
||||
Effort: string(effort),
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
if intent.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
state := &dto.ReasoningConversionState{
|
||||
Mode: string(intent.Mode),
|
||||
Effort: string(intent.Effort),
|
||||
BudgetTokens: intent.BudgetTokens,
|
||||
IncludeThoughts: intent.IncludeThoughts,
|
||||
}
|
||||
req.ReasoningConversion = state
|
||||
return nil
|
||||
}
|
||||
|
||||
func FromOpenAIResponses(req *dto.OpenAIResponsesRequest) (Intent, error) {
|
||||
if req == nil {
|
||||
return Intent{}, nil
|
||||
}
|
||||
var intent Intent
|
||||
if req.Reasoning != nil {
|
||||
intent.Source = SourceExplicit
|
||||
if req.Reasoning.Effort != "" {
|
||||
effort, err := ParseEffort(req.Reasoning.Effort)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
intent.Mode = ModeEnabled
|
||||
if effort == EffortNone {
|
||||
intent.Mode = ModeDisabled
|
||||
}
|
||||
}
|
||||
if req.Reasoning.Summary != "" {
|
||||
include := true
|
||||
intent.IncludeThoughts = &include
|
||||
}
|
||||
}
|
||||
if req.ReasoningConversion == nil {
|
||||
return normalizeIntent(intent)
|
||||
}
|
||||
pivot := Intent{
|
||||
Mode: Mode(req.ReasoningConversion.Mode),
|
||||
Effort: Effort(req.ReasoningConversion.Effort),
|
||||
BudgetTokens: req.ReasoningConversion.BudgetTokens,
|
||||
IncludeThoughts: req.ReasoningConversion.IncludeThoughts,
|
||||
Source: SourcePivot,
|
||||
BudgetSource: SourcePivot,
|
||||
}
|
||||
if req.Reasoning != nil && req.Reasoning.Effort != "" {
|
||||
pivotEffort := EffectiveEffort(pivot)
|
||||
if Effort(req.Reasoning.Effort) == pivotEffort {
|
||||
intent.Effort = ""
|
||||
intent.Mode = ModeUnset
|
||||
}
|
||||
}
|
||||
return MergeExplicit(intent, pivot, req.Model)
|
||||
}
|
||||
|
||||
func FromClaude(req *dto.ClaudeRequest) (Intent, error) {
|
||||
if req == nil {
|
||||
return Intent{}, nil
|
||||
}
|
||||
var intent Intent
|
||||
intent.Source = SourceNative
|
||||
if req.Thinking != nil {
|
||||
switch req.Thinking.Type {
|
||||
case "", "enabled":
|
||||
intent.Mode = ModeEnabled
|
||||
case "adaptive":
|
||||
intent.Mode = ModeAdaptive
|
||||
case "disabled":
|
||||
intent.Mode = ModeDisabled
|
||||
intent.Effort = EffortNone
|
||||
default:
|
||||
return Intent{}, fmt.Errorf("unsupported Claude thinking type %q", req.Thinking.Type)
|
||||
}
|
||||
intent.BudgetTokens = req.Thinking.BudgetTokens
|
||||
if req.Thinking.BudgetTokens != nil {
|
||||
budget := *req.Thinking.BudgetTokens
|
||||
if budget < 1024 {
|
||||
return Intent{}, fmt.Errorf("Claude thinking budget_tokens must be at least 1024, got %d", budget)
|
||||
}
|
||||
if req.MaxTokens != nil && uint(budget) >= *req.MaxTokens {
|
||||
return Intent{}, fmt.Errorf("Claude thinking budget_tokens must be less than max_tokens")
|
||||
}
|
||||
intent.BudgetSource = SourceNative
|
||||
}
|
||||
switch req.Thinking.Display {
|
||||
case "summarized":
|
||||
include := true
|
||||
intent.IncludeThoughts = &include
|
||||
case "omitted":
|
||||
include := false
|
||||
intent.IncludeThoughts = &include
|
||||
}
|
||||
}
|
||||
if len(req.OutputConfig) > 0 {
|
||||
var output dto.OutputConfigForEffort
|
||||
if err := kitutil.Unmarshal(req.OutputConfig, &output); err != nil {
|
||||
return Intent{}, fmt.Errorf("invalid Claude output_config: %w", err)
|
||||
}
|
||||
if output.Effort != "" {
|
||||
effort, err := ParseEffort(output.Effort)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
}
|
||||
}
|
||||
if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
|
||||
return intent, nil
|
||||
}
|
||||
return normalizeIntent(intent)
|
||||
}
|
||||
|
||||
func FromGemini(req *dto.GeminiChatRequest) (Intent, error) {
|
||||
if req == nil || req.GenerationConfig.ThinkingConfig == nil {
|
||||
return Intent{}, nil
|
||||
}
|
||||
config := req.GenerationConfig.ThinkingConfig
|
||||
if config.ThinkingBudget != nil && config.ThinkingLevel != "" {
|
||||
return Intent{}, fmt.Errorf("%w: Gemini thinkingBudget and thinkingLevel cannot both be set", ErrEffortConflict)
|
||||
}
|
||||
intent := Intent{
|
||||
BudgetTokens: config.ThinkingBudget,
|
||||
IncludeThoughts: config.IncludeThoughts,
|
||||
Source: SourceNative,
|
||||
BudgetSource: SourceNative,
|
||||
}
|
||||
if config.ThinkingLevel != "" {
|
||||
effort, err := ParseEffort(config.ThinkingLevel)
|
||||
if err != nil {
|
||||
return Intent{}, err
|
||||
}
|
||||
intent.Effort = effort
|
||||
intent.Mode = ModeEnabled
|
||||
}
|
||||
return normalizeIntent(intent)
|
||||
}
|
||||
|
||||
func EffectiveEffort(intent Intent) Effort {
|
||||
intent, err := normalizeIntent(intent)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if intent.Mode == ModeDisabled {
|
||||
return EffortNone
|
||||
}
|
||||
if intent.Effort != "" {
|
||||
return intent.Effort
|
||||
}
|
||||
if intent.BudgetTokens != nil {
|
||||
return EffortFromBudget(*intent.BudgetTokens)
|
||||
}
|
||||
if intent.Mode == ModeEnabled || intent.Mode == ModeAdaptive {
|
||||
return EffortHigh
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func EffortFromBudget(budget int) Effort {
|
||||
if budget == 0 {
|
||||
return EffortNone
|
||||
}
|
||||
if budget < 0 {
|
||||
return EffortHigh
|
||||
}
|
||||
if budget <= 1024 {
|
||||
return EffortLow
|
||||
}
|
||||
if budget <= 8192 {
|
||||
return EffortMedium
|
||||
}
|
||||
return EffortHigh
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMergeExplicitAndSuffix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
budget1024 := 1024
|
||||
budget2048 := 2048
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
explicit Intent
|
||||
suffix Intent
|
||||
wantErr bool
|
||||
wantMode Mode
|
||||
wantEffort Effort
|
||||
wantBudget *int
|
||||
wantThoughts *bool
|
||||
}{
|
||||
{
|
||||
name: "enabled plus matching effort merges",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
|
||||
suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: EffortHigh,
|
||||
},
|
||||
{
|
||||
name: "enabled vs disabled conflict",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
|
||||
suffix: Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "different efforts conflict",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortLow},
|
||||
suffix: Intent{Mode: ModeEnabled, Effort: EffortHigh, Source: SourceSuffix},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "different budgets conflict",
|
||||
explicit: Intent{BudgetTokens: &budget1024},
|
||||
suffix: Intent{BudgetTokens: &budget2048, Source: SourceSuffix, BudgetSource: SourceSuffix},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "effort versus exact suffix budget conflict",
|
||||
explicit: Intent{Mode: ModeEnabled, Effort: EffortHigh},
|
||||
suffix: Intent{BudgetTokens: &budget1024, Source: SourceSuffix, BudgetSource: SourceSuffix},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "suffix only is adopted",
|
||||
suffix: Intent{Mode: ModeEnabled, Effort: EffortMedium, Source: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: EffortMedium,
|
||||
wantThoughts: nil,
|
||||
},
|
||||
{
|
||||
name: "explicit include thoughts overlays empty suffix strength",
|
||||
explicit: Intent{IncludeThoughts: boolPtr(false)},
|
||||
suffix: Intent{Mode: ModeEnabled, Effort: EffortLow, Source: SourceSuffix},
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: EffortLow,
|
||||
wantThoughts: boolPtr(false),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := MergeExplicitAndSuffix(tt.explicit, tt.suffix, "claude-opus-4-8")
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrEffortConflict)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMode, got.Mode)
|
||||
assert.Equal(t, tt.wantEffort, got.Effort)
|
||||
if tt.wantBudget != nil {
|
||||
require.NotNil(t, got.BudgetTokens)
|
||||
assert.Equal(t, *tt.wantBudget, *got.BudgetTokens)
|
||||
}
|
||||
if tt.wantThoughts != nil {
|
||||
require.NotNil(t, got.IncludeThoughts)
|
||||
assert.Equal(t, *tt.wantThoughts, *got.IncludeThoughts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentStateRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
budget := 4096
|
||||
include := true
|
||||
intent := Intent{
|
||||
Mode: ModeEnabled,
|
||||
Effort: EffortHigh,
|
||||
BudgetTokens: &budget,
|
||||
IncludeThoughts: &include,
|
||||
}
|
||||
|
||||
state := StateFromIntent(intent)
|
||||
require.NotNil(t, state)
|
||||
got := IntentFromState(state)
|
||||
assert.Equal(t, intent.Mode, got.Mode)
|
||||
assert.Equal(t, intent.Effort, got.Effort)
|
||||
require.NotNil(t, got.BudgetTokens)
|
||||
assert.Equal(t, budget, *got.BudgetTokens)
|
||||
require.NotNil(t, got.IncludeThoughts)
|
||||
assert.True(t, *got.IncludeThoughts)
|
||||
assert.True(t, IntentFromState(nil).IsEmpty())
|
||||
assert.Nil(t, StateFromIntent(Intent{}))
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestOpenAIPivotRetainsExactStrengthAndBudget(t *testing.T) {
|
||||
budget, include := 16384, false
|
||||
for _, effort := range []Effort{EffortMax, EffortXHigh} {
|
||||
t.Run(string(effort), func(t *testing.T) {
|
||||
intent := Intent{Mode: ModeEnabled, Effort: effort, BudgetTokens: &budget, IncludeThoughts: &include}
|
||||
chat := &dto.GeneralOpenAIRequest{}
|
||||
require.NoError(t, ApplyToOpenAIChat(chat, intent))
|
||||
assert.Equal(t, string(effort), chat.ReasoningEffort)
|
||||
restored, err := FromOpenAIChat(chat)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, effort, restored.Effort)
|
||||
require.NotNil(t, restored.BudgetTokens)
|
||||
assert.Equal(t, budget, *restored.BudgetTokens)
|
||||
require.NotNil(t, restored.IncludeThoughts)
|
||||
assert.False(t, *restored.IncludeThoughts)
|
||||
|
||||
responses := &dto.OpenAIResponsesRequest{}
|
||||
require.NoError(t, ApplyToOpenAIResponses(responses, restored))
|
||||
require.NotNil(t, responses.Reasoning)
|
||||
assert.Equal(t, string(effort), responses.Reasoning.Effort)
|
||||
restored, err = FromOpenAIResponses(responses)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, effort, restored.Effort)
|
||||
require.NotNil(t, restored.BudgetTokens)
|
||||
assert.Equal(t, budget, *restored.BudgetTokens)
|
||||
require.NotNil(t, restored.IncludeThoughts)
|
||||
assert.False(t, *restored.IncludeThoughts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIPivotDoesNotTreatMaxAndXHighAsEquivalent(t *testing.T) {
|
||||
intent := Intent{Mode: ModeEnabled, Effort: EffortMax}
|
||||
chat := &dto.GeneralOpenAIRequest{}
|
||||
require.NoError(t, ApplyToOpenAIChat(chat, intent))
|
||||
chat.ReasoningEffort = "xhigh"
|
||||
_, err := FromOpenAIChat(chat)
|
||||
require.ErrorIs(t, err, ErrEffortConflict)
|
||||
|
||||
responses := &dto.OpenAIResponsesRequest{}
|
||||
require.NoError(t, ApplyToOpenAIResponses(responses, intent))
|
||||
responses.Reasoning.Effort = "xhigh"
|
||||
_, err = FromOpenAIResponses(responses)
|
||||
require.ErrorIs(t, err, ErrEffortConflict)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
|
||||
|
||||
var OpenAIEffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
|
||||
|
||||
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)
|
||||
})
|
||||
if !found {
|
||||
return modelName, "", false
|
||||
}
|
||||
return strings.TrimSuffix(modelName, suffix), strings.TrimPrefix(suffix, "-"), true
|
||||
}
|
||||
|
||||
// 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 || !legacyOpenAIModelPattern.MatchString(lastModelPathSegment(baseModel)) {
|
||||
return "", modelName
|
||||
}
|
||||
return effort, baseModel
|
||||
}
|
||||
|
||||
func ParseClaudeModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
|
||||
prefix, bare := splitModelNamespace(modelName)
|
||||
if !strings.HasPrefix(bare, "claude-") {
|
||||
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 prefix + base, intent, true, nil
|
||||
}
|
||||
|
||||
func isKnownClaudeModel(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")
|
||||
}
|
||||
knownPrefixes := []string{
|
||||
"claude-fable-5", "claude-mythos-5", "claude-mythos-preview",
|
||||
"claude-opus-5", "claude-sonnet-5", "claude-opus-4-8",
|
||||
"claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6",
|
||||
"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5",
|
||||
"claude-opus-4-1", "claude-opus-4-", "claude-sonnet-4-",
|
||||
"claude-3-7-sonnet",
|
||||
}
|
||||
for _, prefix := range knownPrefixes {
|
||||
if strings.HasPrefix(baseModel, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseGeminiModelSuffix(modelName string, allowThinkingAlias bool) (string, Intent, bool, error) {
|
||||
prefix, bare := splitModelNamespace(modelName)
|
||||
if !strings.HasPrefix(bare, "gemini-") {
|
||||
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 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) {
|
||||
bare := lastModelPathSegment(modelName)
|
||||
if strings.HasPrefix(bare, "claude-") {
|
||||
return ParseClaudeModelSuffix(modelName, allowThinkingAlias)
|
||||
}
|
||||
if strings.HasPrefix(bare, "gemini-") {
|
||||
return ParseGeminiModelSuffix(modelName, allowThinkingAlias)
|
||||
}
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
|
||||
func splitModelNamespace(modelName string) (string, string) {
|
||||
if slash := strings.LastIndex(modelName, "/"); slash >= 0 {
|
||||
return modelName[:slash+1], modelName[slash+1:]
|
||||
}
|
||||
return "", modelName
|
||||
}
|
||||
|
||||
func lastModelPathSegment(modelName string) string {
|
||||
_, bare := splitModelNamespace(modelName)
|
||||
return bare
|
||||
}
|
||||
|
||||
func TrimGeminiThinkingSuffix(modelName string) (string, bool) {
|
||||
baseModel, _, ok, err := ParseGeminiModelSuffix(modelName, true)
|
||||
return baseModel, ok && err == nil
|
||||
}
|
||||
|
||||
func parseProviderModelSuffix(modelName string, requiredPrefix string, allowThinkingAlias bool, includeThoughts bool) (string, Intent, bool, error) {
|
||||
if allowThinkingAlias {
|
||||
if marker := strings.LastIndex(modelName, "-thinking-"); marker >= 0 {
|
||||
baseModel := modelName[:marker]
|
||||
if !strings.HasPrefix(baseModel, requiredPrefix) {
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
budget, err := strconv.Atoi(modelName[marker+len("-thinking-"):])
|
||||
if err != nil {
|
||||
return modelName, Intent{}, false, fmt.Errorf("invalid thinking budget suffix on model %q: %w", modelName, err)
|
||||
}
|
||||
intent := Intent{BudgetTokens: &budget, Source: SourceSuffix, BudgetSource: SourceSuffix}
|
||||
if includeThoughts {
|
||||
value := true
|
||||
intent.IncludeThoughts = &value
|
||||
}
|
||||
return baseModel, intent, true, nil
|
||||
}
|
||||
if before, ok := strings.CutSuffix(modelName, "-nothinking"); ok {
|
||||
baseModel := before
|
||||
return baseModel, Intent{Mode: ModeDisabled, Effort: EffortNone, Source: SourceSuffix}, true, nil
|
||||
}
|
||||
if before, ok := strings.CutSuffix(modelName, "-thinking"); ok {
|
||||
baseModel := before
|
||||
intent := Intent{Mode: ModeEnabled, Source: SourceSuffix}
|
||||
if includeThoughts {
|
||||
value := true
|
||||
intent.IncludeThoughts = &value
|
||||
}
|
||||
return baseModel, intent, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
suffixes := []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal", "-none"}
|
||||
baseModel, rawEffort, ok := TrimEffortSuffixWithSuffixes(modelName, suffixes)
|
||||
if !ok || !strings.HasPrefix(baseModel, requiredPrefix) {
|
||||
return modelName, Intent{}, false, nil
|
||||
}
|
||||
effort, err := ParseEffort(rawEffort)
|
||||
if err != nil {
|
||||
return modelName, Intent{}, false, err
|
||||
}
|
||||
intent := Intent{Effort: effort, Mode: ModeEnabled, Source: SourceSuffix}
|
||||
if effort == EffortNone {
|
||||
intent.Mode = ModeDisabled
|
||||
} else if includeThoughts {
|
||||
value := true
|
||||
intent.IncludeThoughts = &value
|
||||
}
|
||||
return baseModel, intent, true, nil
|
||||
}
|
||||
|
||||
func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) {
|
||||
baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes)
|
||||
if !ok || !strings.HasPrefix(baseModel, "deepseek-v4-") {
|
||||
return modelName, "", "", false
|
||||
}
|
||||
switch suffix {
|
||||
case "none":
|
||||
return baseModel, "disabled", "", true
|
||||
case "max":
|
||||
return baseModel, "enabled", "max", true
|
||||
default:
|
||||
return modelName, "", "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseGeminiModelSuffixNoThinkingDisablesReasoning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, intent, found, err := ParseGeminiModelSuffix("gemini-2.5-flash-nothinking", true)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "gemini-2.5-flash", base)
|
||||
assert.Equal(t, ModeDisabled, intent.Mode)
|
||||
assert.Equal(t, EffortNone, intent.Effort)
|
||||
assert.Equal(t, SourceSuffix, intent.Source)
|
||||
}
|
||||
|
||||
func TestParseKnownProviderModelSuffix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
preserveQwenMax := func(name string) bool { return name == "qwen-max" || name == "vendor/qwen-max" }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
allowThinkingAlias bool
|
||||
wantBase string
|
||||
wantFound bool
|
||||
wantMode Mode
|
||||
wantEffort Effort
|
||||
wantBudget *int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "claude thinking alias",
|
||||
model: "claude-3-7-sonnet-thinking",
|
||||
allowThinkingAlias: true,
|
||||
wantBase: "claude-3-7-sonnet",
|
||||
wantFound: true,
|
||||
wantMode: ModeEnabled,
|
||||
},
|
||||
{
|
||||
name: "claude nothinking alias",
|
||||
model: "claude-3-7-sonnet-nothinking",
|
||||
allowThinkingAlias: true,
|
||||
wantBase: "claude-3-7-sonnet",
|
||||
wantFound: true,
|
||||
wantMode: ModeDisabled,
|
||||
wantEffort: EffortNone,
|
||||
},
|
||||
{
|
||||
name: "claude thinking budget",
|
||||
model: "claude-3-7-sonnet-thinking-8192",
|
||||
allowThinkingAlias: true,
|
||||
wantBase: "claude-3-7-sonnet",
|
||||
wantFound: true,
|
||||
wantBudget: intPtr(8192),
|
||||
},
|
||||
{
|
||||
name: "claude effort tail",
|
||||
model: "claude-opus-4-8-high",
|
||||
allowThinkingAlias: true,
|
||||
wantBase: "claude-opus-4-8",
|
||||
wantFound: true,
|
||||
wantMode: ModeEnabled,
|
||||
wantEffort: EffortHigh,
|
||||
},
|
||||
{
|
||||
name: "gemini thinking alias",
|
||||
model: "gemini-2.5-flash-thinking",
|
||||
allowThinkingAlias: true,
|
||||
wantBase: "gemini-2.5-flash",
|
||||
wantFound: true,
|
||||
wantMode: ModeEnabled,
|
||||
},
|
||||
{
|
||||
name: "malformed thinking budget",
|
||||
model: "claude-3-7-sonnet-thinking-abc",
|
||||
allowThinkingAlias: true,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unknown openai-compatible name is untouched",
|
||||
model: "gpt-4o-mini",
|
||||
wantBase: "gpt-4o-mini",
|
||||
wantFound: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
base, intent, found, err := ParseKnownProviderModelSuffix(tt.model, tt.allowThinkingAlias)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantFound, found)
|
||||
assert.Equal(t, tt.wantBase, base)
|
||||
assert.Equal(t, tt.wantMode, intent.Mode)
|
||||
assert.Equal(t, tt.wantEffort, intent.Effort)
|
||||
if tt.wantBudget != nil {
|
||||
require.NotNil(t, intent.BudgetTokens)
|
||||
assert.Equal(t, *tt.wantBudget, *intent.BudgetTokens)
|
||||
} else {
|
||||
assert.Nil(t, intent.BudgetTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("openai effort tail", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.6-sol-high", nil)
|
||||
assert.Equal(t, "high", effort)
|
||||
assert.Equal(t, "gpt-5.6-sol", base)
|
||||
})
|
||||
|
||||
t.Run("preserve effort tail on real model id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("qwen-max", preserveQwenMax)
|
||||
assert.Empty(t, effort)
|
||||
assert.Equal(t, "qwen-max", base)
|
||||
})
|
||||
|
||||
t.Run("preserve effort tail with vendor prefix", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("vendor/qwen-max", preserveQwenMax)
|
||||
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 {
|
||||
return &v
|
||||
}
|
||||
Reference in new issue
Block a user