Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
868
backend/internal/thinking/apply.go
Normal file
868
backend/internal/thinking/apply.go
Normal file
|
|
@ -0,0 +1,868 @@
|
|||
// Package thinking provides unified thinking configuration processing.
|
||||
package thinking
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type pluginProviderApplier struct {
|
||||
owner string
|
||||
priority int
|
||||
applier ProviderApplier
|
||||
}
|
||||
|
||||
var providerAppliersMu sync.RWMutex
|
||||
|
||||
// nativeProviderAppliers maps built-in provider names to their implementations.
|
||||
var nativeProviderAppliers = map[string]ProviderApplier{
|
||||
"gemini": nil,
|
||||
"claude": nil,
|
||||
"openai": nil,
|
||||
"codex": nil,
|
||||
"antigravity": nil,
|
||||
"kimi": nil,
|
||||
"xai": nil,
|
||||
}
|
||||
|
||||
// pluginProviderAppliers maps plugin-owned provider names to their implementations.
|
||||
var pluginProviderAppliers = map[string]pluginProviderApplier{}
|
||||
|
||||
// GetProviderApplier returns the ProviderApplier for the given provider name.
|
||||
// Returns nil if the provider is not registered.
|
||||
func GetProviderApplier(provider string) ProviderApplier {
|
||||
provider = normalizedProviderName(provider)
|
||||
if provider == "" {
|
||||
return nil
|
||||
}
|
||||
providerAppliersMu.RLock()
|
||||
defer providerAppliersMu.RUnlock()
|
||||
if nativeApplier, okNative := nativeProviderAppliers[provider]; okNative {
|
||||
return nativeApplier
|
||||
}
|
||||
return pluginProviderAppliers[provider].applier
|
||||
}
|
||||
|
||||
// RegisterProvider registers a provider applier by name.
|
||||
func RegisterProvider(name string, applier ProviderApplier) {
|
||||
name = normalizedProviderName(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
providerAppliersMu.Lock()
|
||||
defer providerAppliersMu.Unlock()
|
||||
nativeProviderAppliers[name] = applier
|
||||
}
|
||||
|
||||
// RegisterPluginProvider registers a plugin-owned provider applier.
|
||||
func RegisterPluginProvider(owner string, name string, priority int, applier ProviderApplier) bool {
|
||||
owner = strings.TrimSpace(owner)
|
||||
name = normalizedProviderName(name)
|
||||
if owner == "" || name == "" || applier == nil {
|
||||
return false
|
||||
}
|
||||
providerAppliersMu.Lock()
|
||||
defer providerAppliersMu.Unlock()
|
||||
if _, native := nativeProviderAppliers[name]; native {
|
||||
return false
|
||||
}
|
||||
current, exists := pluginProviderAppliers[name]
|
||||
if exists && (current.priority > priority || (current.priority == priority && current.owner <= owner)) {
|
||||
return false
|
||||
}
|
||||
pluginProviderAppliers[name] = pluginProviderApplier{
|
||||
owner: owner,
|
||||
priority: priority,
|
||||
applier: applier,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UnregisterPluginProviders removes all provider appliers owned by one plugin.
|
||||
func UnregisterPluginProviders(owner string) {
|
||||
owner = strings.TrimSpace(owner)
|
||||
if owner == "" {
|
||||
return
|
||||
}
|
||||
providerAppliersMu.Lock()
|
||||
defer providerAppliersMu.Unlock()
|
||||
for provider, record := range pluginProviderAppliers {
|
||||
if record.owner == owner {
|
||||
delete(pluginProviderAppliers, provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPluginProviders removes all plugin-owned provider appliers.
|
||||
func ClearPluginProviders() {
|
||||
providerAppliersMu.Lock()
|
||||
defer providerAppliersMu.Unlock()
|
||||
pluginProviderAppliers = map[string]pluginProviderApplier{}
|
||||
}
|
||||
|
||||
func normalizedProviderName(provider string) string {
|
||||
return strings.ToLower(strings.TrimSpace(provider))
|
||||
}
|
||||
|
||||
// IsUserDefinedModel reports whether the model is a user-defined model that should
|
||||
// have thinking configuration passed through without validation.
|
||||
//
|
||||
// User-defined models are configured via config file's models[] array
|
||||
// (e.g., openai-compatibility.*.models[], *-api-key.models[]). These models
|
||||
// are marked with UserDefined=true at registration time.
|
||||
//
|
||||
// User-defined models should have their thinking configuration applied directly,
|
||||
// letting the upstream service validate the configuration.
|
||||
func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool {
|
||||
if modelInfo == nil {
|
||||
return true
|
||||
}
|
||||
return modelInfo.UserDefined
|
||||
}
|
||||
|
||||
// ApplyThinking applies thinking configuration to a request body.
|
||||
//
|
||||
// This is the unified entry point for all providers. It follows the processing
|
||||
// order defined in FR25: route check → model capability query → config extraction
|
||||
// → validation → application.
|
||||
//
|
||||
// Suffix Priority: When the model name includes a thinking suffix (e.g., "gemini-2.5-pro(8192)"),
|
||||
// the suffix configuration takes priority over any thinking parameters in the request body.
|
||||
// This enables users to override thinking settings via the model name without modifying their
|
||||
// request payload.
|
||||
//
|
||||
// Parameters:
|
||||
// - body: Original request body JSON
|
||||
// - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)")
|
||||
// - fromFormat: Source request format (e.g., openai, codex, gemini)
|
||||
// - toFormat: Target provider format for the request body (gemini, antigravity, claude, openai, codex, kimi, xai)
|
||||
// - providerKey: Provider identifier used for registry model lookups (may differ from toFormat, e.g., openrouter -> openai)
|
||||
//
|
||||
// Returns:
|
||||
// - Modified request body JSON with thinking configuration applied
|
||||
// - Error if validation fails (ThinkingError). On error, the original body
|
||||
// is returned (not nil) to enable defensive programming patterns.
|
||||
//
|
||||
// Passthrough behavior (returns original body without error):
|
||||
// - Unknown provider (not in providerAppliers map)
|
||||
// - modelInfo.Thinking is nil (model doesn't support thinking)
|
||||
//
|
||||
// Note: Unknown models (modelInfo is nil) are treated as user-defined models: we skip
|
||||
// validation and still apply the thinking config so the upstream can validate it.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // With suffix - suffix config takes priority
|
||||
// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro(8192)", "gemini", "gemini", "gemini")
|
||||
//
|
||||
// // Without suffix - uses body config
|
||||
// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini", "gemini", "gemini")
|
||||
func ApplyThinking(body []byte, model string, fromFormat string, toFormat string, providerKey string) ([]byte, error) {
|
||||
summaryConfig := ExtractSummaryConfig(body, toFormat)
|
||||
return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig)
|
||||
}
|
||||
|
||||
// ApplyThinkingWithSummary applies canonical thinking effort while preserving
|
||||
// summary visibility extracted from the original source request. Callers that
|
||||
// translate before applying thinking must pass the source config explicitly:
|
||||
// a target Claude body can temporarily lack display while disabled thinking is
|
||||
// being rewritten by a model suffix.
|
||||
func ApplyThinkingWithSummary(body []byte, model string, fromFormat string, toFormat string, providerKey string, summaryConfig SummaryConfig) ([]byte, error) {
|
||||
return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig)
|
||||
}
|
||||
|
||||
// ApplyThinkingWithModelInfo applies thinking with the exact configured model
|
||||
// definition selected for an API-key execution attempt while preserving summary
|
||||
// visibility from the original source body.
|
||||
func ApplyThinkingWithModelInfo(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
summaryConfig := ExtractSummaryConfig(sourceBody, fromFormat)
|
||||
if len(sourceBody) == 0 {
|
||||
summaryConfig = ExtractSummaryConfig(body, toFormat)
|
||||
}
|
||||
return ApplyThinkingWithModelInfoAndSummary(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, summaryConfig)
|
||||
}
|
||||
|
||||
// ApplyThinkingWithModelInfoAndSummary applies the exact configured model
|
||||
// definition with a summary intent already resolved across source translation
|
||||
// and plugin normalization.
|
||||
func ApplyThinkingWithModelInfoAndSummary(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo, summaryConfig SummaryConfig) ([]byte, error) {
|
||||
return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true, summaryConfig)
|
||||
}
|
||||
|
||||
func applyThinking(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, resolvedModelInfo *registry.ModelInfo, modelInfoResolved bool, summaryConfig SummaryConfig) ([]byte, error) {
|
||||
providerFormat := strings.ToLower(strings.TrimSpace(toFormat))
|
||||
if modelInfoResolved && providerFormat == "openai-response" {
|
||||
providerFormat = "codex"
|
||||
}
|
||||
providerKey = strings.ToLower(strings.TrimSpace(providerKey))
|
||||
if providerKey == "" {
|
||||
providerKey = providerFormat
|
||||
}
|
||||
fromFormat = strings.ToLower(strings.TrimSpace(fromFormat))
|
||||
if fromFormat == "" {
|
||||
fromFormat = providerFormat
|
||||
}
|
||||
// Summary visibility is orthogonal to thinking effort. Keep the original
|
||||
// source intent before a suffix-specific applier rewrites provider fields,
|
||||
// then restore it after the canonical effort has been applied.
|
||||
// 1. Route check: Get provider applier
|
||||
applier := GetProviderApplier(providerFormat)
|
||||
if applier == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": providerFormat,
|
||||
"model": model,
|
||||
}).Debug("thinking: unknown provider, passthrough |")
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 2. Parse suffix and get modelInfo
|
||||
suffixResult := ParseSuffix(model)
|
||||
baseModel := suffixResult.ModelName
|
||||
// Use provider-specific lookup to handle capability differences across providers.
|
||||
modelInfo := resolvedModelInfo
|
||||
if !modelInfoResolved {
|
||||
modelInfo = registry.LookupModelInfo(baseModel, providerKey)
|
||||
}
|
||||
|
||||
// 3. Model capability check
|
||||
// Unknown models are treated as user-defined so thinking config can still be applied.
|
||||
// The upstream service is responsible for validating the configuration.
|
||||
if IsUserDefinedModel(modelInfo) {
|
||||
return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, providerKey, suffixResult, summaryConfig)
|
||||
}
|
||||
if modelInfo.Thinking == nil {
|
||||
config := extractThinkingConfig(body, providerFormat)
|
||||
if hasThinkingConfig(config) || summaryConfig.Mode != SummaryUnspecified {
|
||||
log.WithFields(log.Fields{
|
||||
"model": baseModel,
|
||||
"provider": providerFormat,
|
||||
}).Debug("thinking: model does not support thinking, stripping config |")
|
||||
return StripThinkingConfig(body, providerFormat), nil
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"provider": providerFormat,
|
||||
"model": baseModel,
|
||||
}).Debug("thinking: model does not support thinking, passthrough |")
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 4. Get config: suffix priority over body
|
||||
var config ThinkingConfig
|
||||
if suffixResult.HasSuffix {
|
||||
config = parseSuffixToConfig(suffixResult.RawSuffix, providerFormat, model)
|
||||
log.WithFields(log.Fields{
|
||||
"provider": providerFormat,
|
||||
"model": model,
|
||||
"mode": config.Mode,
|
||||
"budget": config.Budget,
|
||||
"level": config.Level,
|
||||
}).Debug("thinking: config from model suffix |")
|
||||
} else {
|
||||
if modelInfoResolved && len(sourceBody) > 0 {
|
||||
config = extractSourceThinkingConfig(sourceBody, fromFormat)
|
||||
}
|
||||
if !hasThinkingConfig(config) {
|
||||
config = extractThinkingConfig(body, providerFormat)
|
||||
}
|
||||
if hasThinkingConfig(config) {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": providerFormat,
|
||||
"model": modelInfo.ID,
|
||||
"mode": config.Mode,
|
||||
"budget": config.Budget,
|
||||
"level": config.Level,
|
||||
}).Debug("thinking: original config from request |")
|
||||
}
|
||||
}
|
||||
|
||||
if !hasThinkingConfig(config) {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": providerFormat,
|
||||
"model": modelInfo.ID,
|
||||
}).Debug("thinking: no config found, passthrough |")
|
||||
if modelInfoResolved && providerFormat == "claude" && fromFormat != providerFormat && ExtractSummaryConfig(sourceBody, fromFormat).Mode == SummaryEnabled {
|
||||
// Registry translation can only see aggregate model capabilities. For a
|
||||
// cross-protocol summary-only request it may have activated adaptive
|
||||
// thinking solely to make display valid. The selected API-key model is
|
||||
// authoritative at execution time, so discard that inferred activation
|
||||
// when the exact model supports only manual extended thinking. Use the
|
||||
// source intent here even if a target normalizer removed display; in that
|
||||
// case the inferred amount must disappear with it. Explicit native Claude
|
||||
// thinking never reaches this cross-protocol branch.
|
||||
body = stripInferredClaudeSummaryActivation(body, modelInfo)
|
||||
}
|
||||
return applySummaryConfigForProvider(body, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil
|
||||
}
|
||||
if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) {
|
||||
config.Level = mapConfiguredHighIntent(config.Level, modelInfo)
|
||||
}
|
||||
|
||||
// 5. Validate and normalize configuration
|
||||
validated, err := ValidateConfig(config, modelInfo, fromFormat, providerFormat, suffixResult.HasSuffix)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": providerFormat,
|
||||
"model": modelInfo.ID,
|
||||
"error": err.Error(),
|
||||
}).Warn("thinking: validation failed |")
|
||||
// Return original body on validation failure (defensive programming).
|
||||
// This ensures callers who ignore the error won't receive nil body.
|
||||
// The upstream service will decide how to handle the unmodified request.
|
||||
return body, err
|
||||
}
|
||||
|
||||
// Defensive check: ValidateConfig should never return (nil, nil)
|
||||
if validated == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": providerFormat,
|
||||
"model": modelInfo.ID,
|
||||
}).Warn("thinking: ValidateConfig returned nil config without error, passthrough |")
|
||||
return body, nil
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"provider": providerFormat,
|
||||
"model": modelInfo.ID,
|
||||
"mode": validated.Mode,
|
||||
"budget": validated.Budget,
|
||||
"level": validated.Level,
|
||||
}).Debug("thinking: processed config to apply |")
|
||||
|
||||
// 6. Apply configuration using provider-specific applier, then restore the
|
||||
// target summary intent that was explicit before suffix processing.
|
||||
applied, err := applier.Apply(body, *validated, modelInfo)
|
||||
if err != nil {
|
||||
return applied, err
|
||||
}
|
||||
// A fully disabled amount takes precedence over visibility. Re-applying a
|
||||
// summary-only field can recreate an otherwise removed provider config and
|
||||
// make a default-on model think again.
|
||||
if thinkingIsFullyDisabled(*validated) {
|
||||
return applied, nil
|
||||
}
|
||||
return applySummaryConfigForProvider(applied, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil
|
||||
}
|
||||
|
||||
func thinkingIsFullyDisabled(config ThinkingConfig) bool {
|
||||
return config.Mode == ModeNone && config.Budget == 0 && config.Level == ""
|
||||
}
|
||||
|
||||
func shouldMapConfiguredHighIntent(fromFormat, toFormat string, modelInfo *registry.ModelInfo) bool {
|
||||
fromFormat = strings.ToLower(strings.TrimSpace(fromFormat))
|
||||
toFormat = strings.ToLower(strings.TrimSpace(toFormat))
|
||||
if fromFormat != toFormat {
|
||||
return true
|
||||
}
|
||||
if modelInfo == nil {
|
||||
return false
|
||||
}
|
||||
modelType := strings.ToLower(strings.TrimSpace(modelInfo.Type))
|
||||
return modelType != "" && !isSameProviderFamily(toFormat, modelType)
|
||||
}
|
||||
|
||||
func mapConfiguredHighIntent(level ThinkingLevel, modelInfo *registry.ModelInfo) ThinkingLevel {
|
||||
if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) == 0 {
|
||||
return level
|
||||
}
|
||||
level = ThinkingLevel(strings.ToLower(strings.TrimSpace(string(level))))
|
||||
var candidates []ThinkingLevel
|
||||
switch level {
|
||||
case LevelXHigh:
|
||||
candidates = []ThinkingLevel{LevelXHigh, LevelMax, LevelHigh}
|
||||
case LevelMax:
|
||||
candidates = []ThinkingLevel{LevelMax, LevelXHigh, LevelHigh}
|
||||
default:
|
||||
return level
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if isLevelSupported(string(candidate), modelInfo.Thinking.Levels) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func extractSourceThinkingConfig(body []byte, provider string) ThinkingConfig {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider == "openai-response" {
|
||||
return extractCodexConfig(body)
|
||||
}
|
||||
return extractThinkingConfig(body, provider)
|
||||
}
|
||||
|
||||
// parseSuffixToConfig converts a raw suffix string to ThinkingConfig.
|
||||
//
|
||||
// Parsing priority:
|
||||
// 1. Special values: "none" → ModeNone, "auto"/"-1" → ModeAuto
|
||||
// 2. Level names: "minimal", "low", "medium", "high", "xhigh" → ModeLevel
|
||||
// 3. Numeric values: positive integers → ModeBudget, 0 → ModeNone
|
||||
//
|
||||
// If none of the above match, returns empty ThinkingConfig (treated as no config).
|
||||
func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig {
|
||||
// 1. Try special values first (none, auto, -1)
|
||||
if mode, ok := ParseSpecialSuffix(rawSuffix); ok {
|
||||
switch mode {
|
||||
case ModeNone:
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case ModeAuto:
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try level parsing (minimal, low, medium, high, xhigh)
|
||||
if level, ok := ParseLevelSuffix(rawSuffix); ok {
|
||||
return ThinkingConfig{Mode: ModeLevel, Level: level}
|
||||
}
|
||||
|
||||
// 3. Try numeric parsing
|
||||
if budget, ok := ParseNumericSuffix(rawSuffix); ok {
|
||||
if budget == 0 {
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
}
|
||||
return ThinkingConfig{Mode: ModeBudget, Budget: budget}
|
||||
}
|
||||
|
||||
// Unknown suffix format - return empty config
|
||||
log.WithFields(log.Fields{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"raw_suffix": rawSuffix,
|
||||
}).Debug("thinking: unknown suffix format, treating as no config |")
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
// applyUserDefinedModel applies thinking configuration for user-defined models
|
||||
// without ThinkingSupport validation.
|
||||
func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat, providerKey string, suffixResult SuffixResult, summaryConfig SummaryConfig) ([]byte, error) {
|
||||
// Get model ID for logging
|
||||
modelID := ""
|
||||
if modelInfo != nil {
|
||||
modelID = modelInfo.ID
|
||||
} else {
|
||||
modelID = suffixResult.ModelName
|
||||
}
|
||||
|
||||
// Get config: suffix priority over body
|
||||
var config ThinkingConfig
|
||||
if suffixResult.HasSuffix {
|
||||
config = parseSuffixToConfig(suffixResult.RawSuffix, toFormat, modelID)
|
||||
log.WithFields(log.Fields{
|
||||
"provider": toFormat,
|
||||
"model": modelID,
|
||||
"mode": config.Mode,
|
||||
"budget": config.Budget,
|
||||
"level": config.Level,
|
||||
}).Debug("thinking: config from model suffix |")
|
||||
} else {
|
||||
config = extractThinkingConfig(body, fromFormat)
|
||||
if !hasThinkingConfig(config) && fromFormat != toFormat {
|
||||
config = extractThinkingConfig(body, toFormat)
|
||||
}
|
||||
if hasThinkingConfig(config) {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": toFormat,
|
||||
"model": modelID,
|
||||
"mode": config.Mode,
|
||||
"budget": config.Budget,
|
||||
"level": config.Level,
|
||||
}).Debug("thinking: original config from request |")
|
||||
}
|
||||
}
|
||||
|
||||
if !hasThinkingConfig(config) {
|
||||
log.WithFields(log.Fields{
|
||||
"model": modelID,
|
||||
"provider": toFormat,
|
||||
}).Debug("thinking: user-defined model, passthrough (no config) |")
|
||||
return applySummaryConfigForProvider(body, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil
|
||||
}
|
||||
|
||||
applier := GetProviderApplier(toFormat)
|
||||
if applier == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"model": modelID,
|
||||
"provider": toFormat,
|
||||
}).Debug("thinking: user-defined model, passthrough (unknown provider) |")
|
||||
return body, nil
|
||||
}
|
||||
|
||||
config = normalizeUserDefinedConfig(config, fromFormat, toFormat)
|
||||
log.WithFields(log.Fields{
|
||||
"provider": toFormat,
|
||||
"model": modelID,
|
||||
"mode": config.Mode,
|
||||
"budget": config.Budget,
|
||||
"level": config.Level,
|
||||
}).Debug("thinking: processed config to apply |")
|
||||
applied, err := applier.Apply(body, config, modelInfo)
|
||||
if err != nil {
|
||||
return applied, err
|
||||
}
|
||||
if thinkingIsFullyDisabled(config) {
|
||||
return applied, nil
|
||||
}
|
||||
return applySummaryConfigForProvider(applied, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil
|
||||
}
|
||||
|
||||
func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat string) ThinkingConfig {
|
||||
if config.Mode != ModeLevel {
|
||||
return config
|
||||
}
|
||||
if toFormat == "claude" {
|
||||
return config
|
||||
}
|
||||
if !isBudgetCapableProvider(toFormat) {
|
||||
return config
|
||||
}
|
||||
budget, ok := ConvertLevelToBudget(string(config.Level))
|
||||
if !ok {
|
||||
return config
|
||||
}
|
||||
config.Mode = ModeBudget
|
||||
config.Budget = budget
|
||||
config.Level = ""
|
||||
return config
|
||||
}
|
||||
|
||||
// extractThinkingConfig extracts provider-specific thinking config from request body.
|
||||
func extractThinkingConfig(body []byte, provider string) ThinkingConfig {
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
return extractClaudeConfig(body)
|
||||
case "gemini", "antigravity":
|
||||
return extractGeminiConfig(body, provider)
|
||||
case "interactions":
|
||||
return extractInteractionsConfig(body)
|
||||
case "openai":
|
||||
return extractOpenAIConfig(body)
|
||||
case "codex", "xai":
|
||||
return extractCodexConfig(body)
|
||||
case "kimi":
|
||||
return extractKimiConfig(body)
|
||||
default:
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
}
|
||||
|
||||
func hasThinkingConfig(config ThinkingConfig) bool {
|
||||
return config.Mode != ModeBudget || config.Budget != 0 || config.Level != ""
|
||||
}
|
||||
|
||||
// ExtractReasoningEffort returns the request's thinking setting as a canonical
|
||||
// reasoning_effort label for usage logging. Model suffixes have the same
|
||||
// priority as ApplyThinking: a valid suffix overrides body fields.
|
||||
func ExtractReasoningEffort(body []byte, provider, model string) string {
|
||||
if effort := reasoningEffortFromSuffix(ParseSuffix(model)); effort != "" {
|
||||
return effort
|
||||
}
|
||||
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
config := extractThinkingConfig(body, provider)
|
||||
if !hasThinkingConfig(config) {
|
||||
switch provider {
|
||||
case "openai-response":
|
||||
config = extractCodexConfig(body)
|
||||
case "openai":
|
||||
config = extractCodexConfig(body)
|
||||
}
|
||||
}
|
||||
return reasoningEffortFromConfig(config)
|
||||
}
|
||||
|
||||
// ExtractTranslatedReasoningEffort returns the final provider payload's thinking
|
||||
// setting as a canonical reasoning_effort label for usage logging.
|
||||
func ExtractTranslatedReasoningEffort(body []byte, provider string) string {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
config := extractThinkingConfig(body, provider)
|
||||
if !hasThinkingConfig(config) {
|
||||
switch provider {
|
||||
case "openai", "openai-response":
|
||||
config = extractCodexConfig(body)
|
||||
if !hasThinkingConfig(config) {
|
||||
config = extractOpenAIConfig(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasoningEffortFromConfig(config)
|
||||
}
|
||||
|
||||
func reasoningEffortFromSuffix(suffix SuffixResult) string {
|
||||
if !suffix.HasSuffix {
|
||||
return ""
|
||||
}
|
||||
return reasoningEffortFromConfig(parseSuffixToConfig(suffix.RawSuffix, "", suffix.ModelName))
|
||||
}
|
||||
|
||||
func reasoningEffortFromConfig(config ThinkingConfig) string {
|
||||
if !hasThinkingConfig(config) {
|
||||
return ""
|
||||
}
|
||||
switch config.Mode {
|
||||
case ModeNone:
|
||||
return string(LevelNone)
|
||||
case ModeAuto:
|
||||
return string(LevelAuto)
|
||||
case ModeLevel:
|
||||
return strings.ToLower(strings.TrimSpace(string(config.Level)))
|
||||
case ModeBudget:
|
||||
level, ok := ConvertBudgetToLevel(config.Budget)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return level
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// extractClaudeConfig extracts thinking configuration from Claude format request body.
|
||||
//
|
||||
// Claude API format:
|
||||
// - thinking.type: "enabled" or "disabled"
|
||||
// - thinking.budget_tokens: integer (-1=auto, 0=disabled, >0=budget)
|
||||
//
|
||||
// Priority: thinking.type="disabled" takes precedence over budget_tokens.
|
||||
// When type="enabled" without budget_tokens, returns ModeAuto to indicate
|
||||
// the user wants thinking enabled but didn't specify a budget.
|
||||
func extractClaudeConfig(body []byte) ThinkingConfig {
|
||||
thinkingType := gjson.GetBytes(body, "thinking.type").String()
|
||||
if thinkingType == "disabled" {
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
}
|
||||
if thinkingType == "adaptive" || thinkingType == "auto" {
|
||||
// Claude adaptive thinking uses output_config.effort (low/medium/high/max).
|
||||
// We only treat it as a thinking config when effort is explicitly present;
|
||||
// otherwise we passthrough and let upstream defaults apply.
|
||||
if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() && effort.Type == gjson.String {
|
||||
value := strings.ToLower(strings.TrimSpace(effort.String()))
|
||||
if value == "" {
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
switch value {
|
||||
case "none":
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case "auto":
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
default:
|
||||
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||
}
|
||||
}
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
// Check budget_tokens
|
||||
if budget := gjson.GetBytes(body, "thinking.budget_tokens"); budget.Exists() {
|
||||
value := int(budget.Int())
|
||||
switch value {
|
||||
case 0:
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case -1:
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
default:
|
||||
return ThinkingConfig{Mode: ModeBudget, Budget: value}
|
||||
}
|
||||
}
|
||||
|
||||
// If type="enabled" but no budget_tokens, treat as auto (user wants thinking but no budget specified)
|
||||
if thinkingType == "enabled" {
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
}
|
||||
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
// extractGeminiConfig extracts thinking configuration from Gemini format request body.
|
||||
//
|
||||
// Gemini API format:
|
||||
// - generationConfig.thinkingConfig.thinkingLevel: "none", "auto", or level name (Gemini 3)
|
||||
// - generationConfig.thinkingConfig.thinkingBudget: integer (Gemini 2.5)
|
||||
//
|
||||
// For antigravity providers, the path is prefixed with "request.".
|
||||
//
|
||||
// Priority: thinkingLevel is checked first (Gemini 3 format), then thinkingBudget (Gemini 2.5 format).
|
||||
// This allows newer Gemini 3 level-based configs to take precedence.
|
||||
func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
|
||||
prefix := "generationConfig.thinkingConfig"
|
||||
if provider == "antigravity" {
|
||||
prefix = "request.generationConfig.thinkingConfig"
|
||||
}
|
||||
|
||||
// Check thinkingLevel first (Gemini 3 format takes precedence)
|
||||
level := gjson.GetBytes(body, prefix+".thinkingLevel")
|
||||
if !level.Exists() {
|
||||
// Google official Gemini Python SDK sends snake_case field names
|
||||
level = gjson.GetBytes(body, prefix+".thinking_level")
|
||||
}
|
||||
if level.Exists() {
|
||||
value := level.String()
|
||||
switch value {
|
||||
case "none":
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case "auto":
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
default:
|
||||
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||
}
|
||||
}
|
||||
|
||||
// Check thinkingBudget (Gemini 2.5 format)
|
||||
budget := gjson.GetBytes(body, prefix+".thinkingBudget")
|
||||
if !budget.Exists() {
|
||||
// Google official Gemini Python SDK sends snake_case field names
|
||||
budget = gjson.GetBytes(body, prefix+".thinking_budget")
|
||||
}
|
||||
if budget.Exists() {
|
||||
value := int(budget.Int())
|
||||
switch value {
|
||||
case 0:
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case -1:
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
default:
|
||||
return ThinkingConfig{Mode: ModeBudget, Budget: value}
|
||||
}
|
||||
}
|
||||
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
func extractInteractionsConfig(body []byte) ThinkingConfig {
|
||||
for _, path := range []string{
|
||||
"generation_config.thinking_level",
|
||||
"generation_config.thinkingLevel",
|
||||
"generation_config.thinking_config.thinking_level",
|
||||
"generation_config.thinking_config.thinkingLevel",
|
||||
"generation_config.thinkingConfig.thinking_level",
|
||||
"generation_config.thinkingConfig.thinkingLevel",
|
||||
} {
|
||||
level := gjson.GetBytes(body, path)
|
||||
if !level.Exists() {
|
||||
continue
|
||||
}
|
||||
value := strings.ToLower(strings.TrimSpace(level.String()))
|
||||
switch value {
|
||||
case "none":
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case "auto":
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
default:
|
||||
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"generation_config.thinking_budget",
|
||||
"generation_config.thinkingBudget",
|
||||
"generation_config.thinking_config.thinking_budget",
|
||||
"generation_config.thinking_config.thinkingBudget",
|
||||
"generation_config.thinkingConfig.thinking_budget",
|
||||
"generation_config.thinkingConfig.thinkingBudget",
|
||||
} {
|
||||
budget := gjson.GetBytes(body, path)
|
||||
if !budget.Exists() {
|
||||
continue
|
||||
}
|
||||
value := int(budget.Int())
|
||||
switch value {
|
||||
case 0:
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case -1:
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
default:
|
||||
return ThinkingConfig{Mode: ModeBudget, Budget: value}
|
||||
}
|
||||
}
|
||||
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
// extractOpenAIConfig extracts thinking configuration from OpenAI format request body.
|
||||
//
|
||||
// OpenAI API format:
|
||||
// - reasoning_effort: "none", "low", "medium", "high" (discrete levels)
|
||||
//
|
||||
// OpenAI uses level-based thinking configuration only, no numeric budget support.
|
||||
// The "none" value is treated specially to return ModeNone.
|
||||
func extractOpenAIConfig(body []byte) ThinkingConfig {
|
||||
// Check reasoning_effort (OpenAI Chat Completions format)
|
||||
if effort := gjson.GetBytes(body, "reasoning_effort"); effort.Exists() {
|
||||
value := effort.String()
|
||||
if value == "none" {
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
}
|
||||
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||
}
|
||||
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
// extractKimiConfig extracts Kimi's native thinking object while retaining
|
||||
// reasoning_effort as a legacy input fallback.
|
||||
//
|
||||
// Native fields take precedence over reasoning_effort. In particular,
|
||||
// thinking.type="enabled" without an explicit effort means "use the upstream
|
||||
// default" and therefore returns an empty config so ApplyThinking preserves the
|
||||
// request unchanged instead of interpreting it as CPA's ModeAuto.
|
||||
func extractKimiConfig(body []byte) ThinkingConfig {
|
||||
thinkingType := gjson.GetBytes(body, "thinking.type")
|
||||
if thinkingType.Exists() {
|
||||
switch strings.ToLower(strings.TrimSpace(thinkingType.String())) {
|
||||
case "disabled":
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case "enabled":
|
||||
if !gjson.GetBytes(body, "thinking.effort").Exists() {
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if effort := gjson.GetBytes(body, "thinking.effort"); effort.Exists() {
|
||||
value := strings.ToLower(strings.TrimSpace(effort.String()))
|
||||
switch value {
|
||||
case "":
|
||||
return ThinkingConfig{}
|
||||
case "none":
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
case "auto":
|
||||
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
|
||||
default:
|
||||
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit native thinking object without an effort should be left for
|
||||
// the Kimi upstream to interpret and must not be overridden by the legacy
|
||||
// field.
|
||||
if thinkingType.Exists() {
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
|
||||
return extractOpenAIConfig(body)
|
||||
}
|
||||
|
||||
// extractCodexConfig extracts thinking configuration from Codex format request body.
|
||||
//
|
||||
// Codex API format (OpenAI Responses API):
|
||||
// - reasoning.effort: "none", "low", "medium", "high"
|
||||
//
|
||||
// This is similar to OpenAI but uses nested field "reasoning.effort" instead of "reasoning_effort".
|
||||
func extractCodexConfig(body []byte) ThinkingConfig {
|
||||
// Check reasoning.effort (Codex / OpenAI Responses API format)
|
||||
if effort := gjson.GetBytes(body, "reasoning.effort"); effort.Exists() {
|
||||
value := effort.String()
|
||||
if value == "none" {
|
||||
return ThinkingConfig{Mode: ModeNone, Budget: 0}
|
||||
}
|
||||
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
|
||||
}
|
||||
|
||||
return ThinkingConfig{}
|
||||
}
|
||||
Loading…
Reference in a new issue