Files
new-api/relaykit/relayconvert/reasoning/claude.go
T
Calcium-Ion 0ed497f066 feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)
* feat(relaykit): preserve hosted tools across conversions

- add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies
- bridge citations, grounding metadata, and hosted-tool stream lifecycles
- document the public conversion behavior and channel policy controls

* refactor(relaykit): normalize reasoning and thinking intent

- centralize provider-neutral reasoning intent, effort, and budget mappings
- parse model suffixes at the host entry boundary while preserving provider-owned tails
- keep adaptive Claude thinking and explicit zero-token compatibility consistent

* fix(billing): preserve authoritative usage across relay hops

- carry native BillingUsage sidecars through direct and streamed protocol bridges
- merge partial and terminal usage monotonically with safe fallback settlement
- retain cache metadata, penultimate usage, and per-call Gemini tool surcharges

* feat(relay): bridge Responses with Claude and Gemini protocols

- add direct request, response, and stream converters across supported relay formats
- expose Claude count_tokens and Chat-to-Responses compatibility endpoints
- carry conversion diagnostics through the host while retaining the curated public goldens

* fix(relay): wire relaykit conversions into host channels

- connect handlers, adaptors, and channel settings to the standalone conversion layer
- keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned
- ignore local audit artifacts and retain focused public regression coverage
2026-09-01 21:53:35 +08:00

304 lines
9.2 KiB
Go

package reasoning
import (
"fmt"
"math"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
)
type ClaudeRender struct {
Thinking *dto.Thinking
OutputEffort Effort
EffectiveEffort Effort
ClearSampling bool
ConstrainThinkingSampling bool
}
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) {
if intent.Mode == ModeDisabled && intent.Effort != "" && intent.Effort != EffortNone {
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)
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 strings.HasPrefix(strings.ToLower(model), "claude-opus-5") &&
(intent.Effort == EffortXHigh || intent.Effort == EffortMax) {
return ClaudeRender{}, fmt.Errorf("model %q does not support effort %q while thinking is disabled", model, intent.Effort)
}
if !capabilities.supportsDisable {
return ClaudeRender{}, fmt.Errorf("%w for model %q", ErrThinkingNotDisabled, model)
}
return ClaudeRender{
Thinking: &dto.Thinking{Type: "disabled"},
EffectiveEffort: EffortNone,
ClearSampling: capabilities.strictSampling,
}, nil
}
preferManual := capabilities.supportsManual && intent.BudgetTokens != nil && intent.Mode != ModeAdaptive
if !capabilities.supportsManual && intent.BudgetTokens != nil && intent.BudgetSource == SourceNative && intent.Mode == ModeEnabled {
return ClaudeRender{}, fmt.Errorf("model %q requires adaptive thinking and does not support native budget_tokens", model)
}
if capabilities.adaptive && !preferManual {
effort := intent.Effort
if effort == "" && intent.BudgetTokens != nil {
effort = EffortFromBudget(*intent.BudgetTokens)
}
if effort == "" && intent.Mode == ModeEnabled {
effort = EffortHigh
}
effort = normalizeClaudeEffort(effort, capabilities)
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,
}, 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,
}, nil
}
if intent.Mode == ModeAdaptive {
return ClaudeRender{}, fmt.Errorf("model %q does not support adaptive thinking", model)
}
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 == -1 && intent.BudgetSource == SourceNative {
return ClaudeRender{}, fmt.Errorf("Claude thinking budget_tokens does not support -1")
}
if intent.BudgetTokens != nil && *intent.BudgetTokens >= 0 {
budget = *intent.BudgetTokens
if intent.BudgetSource != SourceNative {
if budget < 1024 {
budget = 1024
}
if uint(budget) >= *maxTokens {
budget = int(*maxTokens) - 1
}
}
if budget < 1024 || uint(budget) >= *maxTokens {
return ClaudeRender{}, fmt.Errorf("Claude thinking budget must satisfy 1024 <= budget_tokens < max_tokens")
}
} else {
percentage := effortPercentage(intent.Effort, adapterBudgetPercentage)
budget = int(*maxTokens) * percentage / 100
if budget < 1024 {
budget = 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,
}, nil
}
// 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
}