mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41:21 +00:00
* 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
375 lines
12 KiB
Go
375 lines
12 KiB
Go
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
|
|
}
|