Files
new-api/relaykit/relayconvert/reasoning/gemini.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

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
}