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{}
|
||||
}
|
||||
226
backend/internal/thinking/apply_configured_api_key_test.go
Normal file
226
backend/internal/thinking/apply_configured_api_key_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package thinking_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestApplyThinkingWithModelInfoMapsCrossFamilyHighIntent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
supported []string
|
||||
want string
|
||||
}{
|
||||
{name: "xhigh stays xhigh", source: "xhigh", supported: []string{"high", "max", "xhigh"}, want: "xhigh"},
|
||||
{name: "xhigh prefers max", source: "xhigh", supported: []string{"high", "max"}, want: "max"},
|
||||
{name: "xhigh falls back to high", source: "xhigh", supported: []string{"high"}, want: "high"},
|
||||
{name: "max stays max", source: "max", supported: []string{"high", "xhigh", "max"}, want: "max"},
|
||||
{name: "max prefers xhigh", source: "max", supported: []string{"high", "xhigh"}, want: "xhigh"},
|
||||
{name: "max falls back to high", source: "max", supported: []string{"high"}, want: "high"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "claude-upstream",
|
||||
Type: "claude",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: tc.supported},
|
||||
}
|
||||
body := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`)
|
||||
source := []byte(`{"reasoning_effort":"` + tc.source + `"}`)
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(body, source, "claude-upstream", "openai", "claude", "claude", modelInfo)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "output_config.effort").String(); got != tc.want {
|
||||
t.Fatalf("output effort = %q, want %q; body=%s", got, tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoMapsOpenAICompatibilityHighIntent(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "compat-upstream",
|
||||
Type: "openai-compatibility",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}},
|
||||
}
|
||||
body := []byte(`{"reasoning_effort":"high"}`)
|
||||
source := []byte(`{"reasoning_effort":"xhigh"}`)
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(body, source, "compat-upstream", "openai", "openai", "compat-provider", modelInfo)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "reasoning_effort").String(); got != "max" {
|
||||
t.Fatalf("reasoning_effort = %q, want max; body=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoMapsResponsesToCodexHighIntent(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "codex-upstream",
|
||||
Type: "codex",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "xhigh"}},
|
||||
}
|
||||
body := []byte(`{"reasoning":{"effort":"high"}}`)
|
||||
source := []byte(`{"reasoning":{"effort":"max"}}`)
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(body, source, "codex-upstream", "openai-response", "codex", "codex", modelInfo)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "xhigh" {
|
||||
t.Fatalf("reasoning.effort = %q, want xhigh; body=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoKeepsSameFamilyValidationStrict(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "openai-upstream",
|
||||
Type: "openai",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}},
|
||||
}
|
||||
body := []byte(`{"reasoning_effort":"xhigh"}`)
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(body, body, "openai-upstream", "openai", "openai", "openai", modelInfo)
|
||||
if err == nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = nil, want unsupported xhigh error; body=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoAppliesEnabledSummaryOnlyClaudeVisibility(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "private-claude",
|
||||
Type: "claude",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}},
|
||||
}
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(
|
||||
[]byte(`{"model":"private-claude","max_tokens":32000}`),
|
||||
[]byte(`{"reasoning":{"summary":"auto"}}`),
|
||||
"private-claude", "openai-response", "claude", "claude", modelInfo,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
|
||||
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
|
||||
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoAndSummaryDropsInferredClaudeModeWhenSummaryRemoved(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "private-manual-claude",
|
||||
Type: "claude",
|
||||
Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 16000},
|
||||
}
|
||||
out, err := thinking.ApplyThinkingWithModelInfoAndSummary(
|
||||
[]byte(`{"model":"private-manual-claude","max_tokens":32000,"thinking":{"type":"adaptive"}}`),
|
||||
[]byte(`{"reasoning":{"summary":"auto"}}`),
|
||||
"private-manual-claude", "openai-response", "claude", "claude", modelInfo,
|
||||
thinking.SummaryConfig{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfoAndSummary() error = %v", err)
|
||||
}
|
||||
if gjson.GetBytes(out, "thinking").Exists() {
|
||||
t.Fatalf("removed summary retained globally inferred adaptive thinking: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoDoesNotActivateClaudeForDisabledSummary(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "private-claude",
|
||||
Type: "claude",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"high"}},
|
||||
}
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(
|
||||
[]byte(`{"model":"private-claude","max_tokens":32000}`),
|
||||
[]byte(`{"reasoning":{"summary":null}}`),
|
||||
"private-claude", "openai-response", "claude", "claude", modelInfo,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
|
||||
}
|
||||
if gjson.GetBytes(out, "thinking").Exists() {
|
||||
t.Fatalf("disabled summary activated Claude thinking: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoSummaryOnlyDoesNotInventOpenAIEffort(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "private-openai",
|
||||
Type: "openai",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}},
|
||||
}
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(
|
||||
[]byte(`{"model":"private-openai","messages":[{"role":"user","content":"hi"}]}`),
|
||||
[]byte(`{"model":"private-openai","reasoning":{"summary":"auto"},"input":"hi"}`),
|
||||
"private-openai", "openai-response", "openai", "openai", modelInfo,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = %v; body=%s", err, out)
|
||||
}
|
||||
if gjson.GetBytes(out, "reasoning_effort").Exists() {
|
||||
t.Fatalf("summary-only request invented reasoning_effort: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithSummaryKeepsOpenAIChatSuffixNone(t *testing.T) {
|
||||
out, err := thinking.ApplyThinkingWithSummary(
|
||||
[]byte(`{"model":"private-openai","messages":[{"role":"user","content":"hi"}]}`),
|
||||
"private-openai(none)", "openai-response", "openai", "openai",
|
||||
thinking.SummaryConfig{Mode: thinking.SummaryEnabled, Detail: "auto"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithSummary() error = %v; body=%s", err, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "reasoning_effort").String(); got != "none" {
|
||||
t.Fatalf("reasoning_effort = %q, want none; body=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoUsesOpenRouterVisibility(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "openrouter-model",
|
||||
Type: "openai-compatibility",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}},
|
||||
}
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(
|
||||
[]byte(`{"model":"openrouter-model","messages":[{"role":"user","content":"hi"}]}`),
|
||||
[]byte(`{"model":"openrouter-model","reasoning":{"summary":"auto"},"input":"hi"}`),
|
||||
"openrouter-model", "openai-response", "openai", "openrouter", modelInfo,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = %v; body=%s", err, out)
|
||||
}
|
||||
if exclude := gjson.GetBytes(out, "reasoning.exclude"); !exclude.Exists() || exclude.Bool() {
|
||||
t.Fatalf("OpenRouter summary visibility not enabled: %s", out)
|
||||
}
|
||||
if gjson.GetBytes(out, "reasoning_effort").Exists() {
|
||||
t.Fatalf("OpenRouter summary visibility invented reasoning_effort: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyThinkingWithModelInfoUsesOriginalResponsesEffort(t *testing.T) {
|
||||
modelInfo := ®istry.ModelInfo{
|
||||
ID: "claude-upstream",
|
||||
Type: "claude",
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"high", "max"}},
|
||||
}
|
||||
body := []byte(`{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`)
|
||||
source := []byte(`{"reasoning":{"effort":"xhigh"}}`)
|
||||
out, err := thinking.ApplyThinkingWithModelInfo(body, source, "claude-upstream", "openai-response", "claude", "claude", modelInfo)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "output_config.effort").String(); got != "max" {
|
||||
t.Fatalf("output effort = %q, want max; body=%s", got, out)
|
||||
}
|
||||
}
|
||||
183
backend/internal/thinking/convert.go
Normal file
183
backend/internal/thinking/convert.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package thinking
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
// levelToBudgetMap defines the standard Level → Budget mapping.
|
||||
// All keys are lowercase; lookups should use strings.ToLower.
|
||||
var levelToBudgetMap = map[string]int{
|
||||
"none": 0,
|
||||
"auto": -1,
|
||||
"minimal": 512,
|
||||
"low": 1024,
|
||||
"medium": 8192,
|
||||
"high": 24576,
|
||||
"xhigh": 32768,
|
||||
// "max" is used by Claude adaptive thinking effort. We map it to a large budget
|
||||
// and rely on per-model clamping when converting to budget-only providers.
|
||||
"max": 128000,
|
||||
}
|
||||
|
||||
// ConvertLevelToBudget converts a thinking level to a budget value.
|
||||
//
|
||||
// This is a semantic conversion that maps discrete levels to numeric budgets.
|
||||
// Level matching is case-insensitive.
|
||||
//
|
||||
// Level → Budget mapping:
|
||||
// - none → 0
|
||||
// - auto → -1
|
||||
// - minimal → 512
|
||||
// - low → 1024
|
||||
// - medium → 8192
|
||||
// - high → 24576
|
||||
// - xhigh → 32768
|
||||
// - max → 128000
|
||||
//
|
||||
// Returns:
|
||||
// - budget: The converted budget value
|
||||
// - ok: true if level is valid, false otherwise
|
||||
func ConvertLevelToBudget(level string) (int, bool) {
|
||||
budget, ok := levelToBudgetMap[strings.ToLower(level)]
|
||||
return budget, ok
|
||||
}
|
||||
|
||||
// BudgetThreshold constants define the upper bounds for each thinking level.
|
||||
// These are used by ConvertBudgetToLevel for range-based mapping.
|
||||
const (
|
||||
// ThresholdMinimal is the upper bound for "minimal" level (1-512)
|
||||
ThresholdMinimal = 512
|
||||
// ThresholdLow is the upper bound for "low" level (513-1024)
|
||||
ThresholdLow = 1024
|
||||
// ThresholdMedium is the upper bound for "medium" level (1025-8192)
|
||||
ThresholdMedium = 8192
|
||||
// ThresholdHigh is the upper bound for "high" level (8193-24576)
|
||||
ThresholdHigh = 24576
|
||||
)
|
||||
|
||||
// ConvertBudgetToLevel converts a budget value to the nearest thinking level.
|
||||
//
|
||||
// This is a semantic conversion that maps numeric budgets to discrete levels.
|
||||
// Uses threshold-based mapping for range conversion.
|
||||
//
|
||||
// Budget → Level thresholds:
|
||||
// - -1 → auto
|
||||
// - 0 → none
|
||||
// - 1-512 → minimal
|
||||
// - 513-1024 → low
|
||||
// - 1025-8192 → medium
|
||||
// - 8193-24576 → high
|
||||
// - 24577+ → xhigh
|
||||
//
|
||||
// Returns:
|
||||
// - level: The converted thinking level string
|
||||
// - ok: true if budget is valid, false for invalid negatives (< -1)
|
||||
func ConvertBudgetToLevel(budget int) (string, bool) {
|
||||
switch {
|
||||
case budget < -1:
|
||||
// Invalid negative values
|
||||
return "", false
|
||||
case budget == -1:
|
||||
return string(LevelAuto), true
|
||||
case budget == 0:
|
||||
return string(LevelNone), true
|
||||
case budget <= ThresholdMinimal:
|
||||
return string(LevelMinimal), true
|
||||
case budget <= ThresholdLow:
|
||||
return string(LevelLow), true
|
||||
case budget <= ThresholdMedium:
|
||||
return string(LevelMedium), true
|
||||
case budget <= ThresholdHigh:
|
||||
return string(LevelHigh), true
|
||||
default:
|
||||
return string(LevelXHigh), true
|
||||
}
|
||||
}
|
||||
|
||||
// HasLevel reports whether the given target level exists in the levels slice.
|
||||
// Matching is case-insensitive with leading/trailing whitespace trimmed.
|
||||
func HasLevel(levels []string, target string) bool {
|
||||
for _, level := range levels {
|
||||
if strings.EqualFold(strings.TrimSpace(level), target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MapToClaudeEffort maps a generic thinking level string to a Claude adaptive
|
||||
// thinking effort value (low/medium/high/max).
|
||||
//
|
||||
// supportsMax indicates whether the target model supports "max" effort.
|
||||
// Returns the mapped effort and true if the level is valid, or ("", false) otherwise.
|
||||
func MapToClaudeEffort(level string, supportsMax bool) (string, bool) {
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
switch level {
|
||||
case "":
|
||||
return "", false
|
||||
case "minimal":
|
||||
return "low", true
|
||||
case "low", "medium", "high":
|
||||
return level, true
|
||||
case "xhigh", "max":
|
||||
if supportsMax {
|
||||
return "max", true
|
||||
}
|
||||
return "high", true
|
||||
case "auto":
|
||||
return "high", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// ModelCapability describes the thinking format support of a model.
|
||||
type ModelCapability int
|
||||
|
||||
const (
|
||||
// CapabilityUnknown indicates modelInfo is nil (passthrough behavior, internal use).
|
||||
CapabilityUnknown ModelCapability = iota - 1
|
||||
// CapabilityNone indicates model doesn't support thinking (Thinking is nil).
|
||||
CapabilityNone
|
||||
// CapabilityBudgetOnly indicates the model supports numeric budgets only.
|
||||
CapabilityBudgetOnly
|
||||
// CapabilityLevelOnly indicates the model supports discrete levels only.
|
||||
CapabilityLevelOnly
|
||||
// CapabilityHybrid indicates the model supports both budgets and levels.
|
||||
CapabilityHybrid
|
||||
)
|
||||
|
||||
// detectModelCapability determines the thinking format capability of a model.
|
||||
//
|
||||
// This is an internal function used by validation and conversion helpers.
|
||||
// It analyzes the model's ThinkingSupport configuration to classify the model:
|
||||
// - CapabilityNone: modelInfo.Thinking is nil (model doesn't support thinking)
|
||||
// - CapabilityBudgetOnly: Has Min/Max but no Levels (Claude, Gemini 2.5)
|
||||
// - CapabilityLevelOnly: Has Levels but no Min/Max (OpenAI, Codex, Kimi)
|
||||
// - CapabilityHybrid: Has both Min/Max and Levels (Gemini 3)
|
||||
//
|
||||
// Note: Returns a special sentinel value when modelInfo itself is nil (unknown model).
|
||||
func detectModelCapability(modelInfo *registry.ModelInfo) ModelCapability {
|
||||
if modelInfo == nil {
|
||||
return CapabilityUnknown // sentinel for "passthrough" behavior
|
||||
}
|
||||
if modelInfo.Thinking == nil {
|
||||
return CapabilityNone
|
||||
}
|
||||
support := modelInfo.Thinking
|
||||
hasBudget := support.Min > 0 || support.Max > 0
|
||||
hasLevels := len(support.Levels) > 0
|
||||
|
||||
switch {
|
||||
case hasBudget && hasLevels:
|
||||
return CapabilityHybrid
|
||||
case hasBudget:
|
||||
return CapabilityBudgetOnly
|
||||
case hasLevels:
|
||||
return CapabilityLevelOnly
|
||||
default:
|
||||
return CapabilityNone
|
||||
}
|
||||
}
|
||||
82
backend/internal/thinking/errors.go
Normal file
82
backend/internal/thinking/errors.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Package thinking provides unified thinking configuration processing logic.
|
||||
package thinking
|
||||
|
||||
import "net/http"
|
||||
|
||||
// ErrorCode represents the type of thinking configuration error.
|
||||
type ErrorCode string
|
||||
|
||||
// Error codes for thinking configuration processing.
|
||||
const (
|
||||
// ErrInvalidSuffix indicates the suffix format cannot be parsed.
|
||||
// Example: "model(abc" (missing closing parenthesis)
|
||||
ErrInvalidSuffix ErrorCode = "INVALID_SUFFIX"
|
||||
|
||||
// ErrUnknownLevel indicates the level value is not in the valid list.
|
||||
// Example: "model(ultra)" where "ultra" is not a valid level
|
||||
ErrUnknownLevel ErrorCode = "UNKNOWN_LEVEL"
|
||||
|
||||
// ErrThinkingNotSupported indicates the model does not support thinking.
|
||||
// Example: claude-haiku-4-5 does not have thinking capability
|
||||
ErrThinkingNotSupported ErrorCode = "THINKING_NOT_SUPPORTED"
|
||||
|
||||
// ErrLevelNotSupported indicates the model does not support level mode.
|
||||
// Example: using level with a budget-only model
|
||||
ErrLevelNotSupported ErrorCode = "LEVEL_NOT_SUPPORTED"
|
||||
|
||||
// ErrBudgetOutOfRange indicates the budget value is outside model range.
|
||||
// Example: budget 64000 exceeds max 20000
|
||||
ErrBudgetOutOfRange ErrorCode = "BUDGET_OUT_OF_RANGE"
|
||||
|
||||
// ErrProviderMismatch indicates the provider does not match the model.
|
||||
// Example: applying Claude format to a Gemini model
|
||||
ErrProviderMismatch ErrorCode = "PROVIDER_MISMATCH"
|
||||
)
|
||||
|
||||
// ThinkingError represents an error that occurred during thinking configuration processing.
|
||||
//
|
||||
// This error type provides structured information about the error, including:
|
||||
// - Code: A machine-readable error code for programmatic handling
|
||||
// - Message: A human-readable description of the error
|
||||
// - Model: The model name related to the error (optional)
|
||||
// - Details: Additional context information (optional)
|
||||
type ThinkingError struct {
|
||||
// Code is the machine-readable error code
|
||||
Code ErrorCode
|
||||
// Message is the human-readable error description.
|
||||
// Should be lowercase, no trailing period, with context if applicable.
|
||||
Message string
|
||||
// Model is the model name related to this error (optional)
|
||||
Model string
|
||||
// Details contains additional context information (optional)
|
||||
Details map[string]interface{}
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
// Returns the message directly without code prefix.
|
||||
// Use Code field for programmatic error handling.
|
||||
func (e *ThinkingError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// NewThinkingError creates a new ThinkingError with the given code and message.
|
||||
func NewThinkingError(code ErrorCode, message string) *ThinkingError {
|
||||
return &ThinkingError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// NewThinkingErrorWithModel creates a new ThinkingError with model context.
|
||||
func NewThinkingErrorWithModel(code ErrorCode, message, model string) *ThinkingError {
|
||||
return &ThinkingError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Model: model,
|
||||
}
|
||||
}
|
||||
|
||||
// StatusCode implements a portable status code interface for HTTP handlers.
|
||||
func (e *ThinkingError) StatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
33
backend/internal/thinking/kimi_max_clamp_repro_test.go
Normal file
33
backend/internal/thinking/kimi_max_clamp_repro_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package thinking_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/kimi"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// Reproduces Claude Code -> Kimi /v1/messages with effort=max.
|
||||
// KimiExecutor delegates to ClaudeExecutor, so ApplyThinking sees claude/claude.
|
||||
func TestKimiClaudeMessagesMaxClampsToHigh(t *testing.T) {
|
||||
models := registry.GetKimiModels()
|
||||
reg := registry.GetGlobalRegistry()
|
||||
clientID := "test-kimi-max-clamp"
|
||||
reg.RegisterClient(clientID, "kimi", models)
|
||||
t.Cleanup(func() { reg.UnregisterClient(clientID) })
|
||||
|
||||
body := []byte(`{"model":"kimi-k2.5","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`)
|
||||
out, err := thinking.ApplyThinking(body, "kimi-k2.5", "claude", "claude", "claude")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyThinking returned error: %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
|
||||
t.Fatalf("thinking.type = %q, want adaptive", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "output_config.effort").String(); got != "high" {
|
||||
t.Fatalf("output_config.effort = %q, want high", got)
|
||||
}
|
||||
}
|
||||
220
backend/internal/thinking/provider/antigravity/apply.go
Normal file
220
backend/internal/thinking/provider/antigravity/apply.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Package antigravity implements thinking configuration for Antigravity API format.
|
||||
//
|
||||
// Antigravity uses request.generationConfig.thinkingConfig.* path.
|
||||
// but requires additional normalization for Claude models:
|
||||
// - Ensure thinking budget < max_tokens
|
||||
// - Remove thinkingConfig if budget < minimum allowed
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Applier applies thinking configuration for Antigravity API format.
|
||||
type Applier struct{}
|
||||
|
||||
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||
|
||||
// NewApplier creates a new Antigravity thinking applier.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
thinking.RegisterProvider("antigravity", NewApplier())
|
||||
}
|
||||
|
||||
// Apply applies thinking configuration to Antigravity request body.
|
||||
//
|
||||
// For Claude models, additional constraints are applied:
|
||||
// - Ensure thinking budget < max_tokens
|
||||
// - Remove thinkingConfig if budget < minimum allowed
|
||||
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
if thinking.IsUserDefinedModel(modelInfo) {
|
||||
return a.applyCompatible(body, config, modelInfo)
|
||||
}
|
||||
if modelInfo.Thinking == nil {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
isClaude := strings.Contains(strings.ToLower(modelInfo.ID), "claude")
|
||||
|
||||
// ModeAuto: Always use Budget format with thinkingBudget=-1
|
||||
if config.Mode == thinking.ModeAuto {
|
||||
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||
}
|
||||
if config.Mode == thinking.ModeBudget {
|
||||
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||
}
|
||||
|
||||
// For non-auto modes, choose format based on model capabilities
|
||||
support := modelInfo.Thinking
|
||||
if len(support.Levels) > 0 {
|
||||
return a.applyLevelFormat(body, config)
|
||||
}
|
||||
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||
}
|
||||
|
||||
func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
isClaude := false
|
||||
if modelInfo != nil {
|
||||
isClaude = strings.Contains(strings.ToLower(modelInfo.ID), "claude")
|
||||
}
|
||||
|
||||
if config.Mode == thinking.ModeAuto {
|
||||
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||
}
|
||||
|
||||
if config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != "") {
|
||||
return a.applyLevelFormat(body, config)
|
||||
}
|
||||
|
||||
return a.applyBudgetFormat(body, config, modelInfo, isClaude)
|
||||
}
|
||||
|
||||
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget")
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
|
||||
// Normalize includeThoughts field name and retain only documented booleans.
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.includeThoughts")
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||
|
||||
if config.Mode == thinking.ModeNone {
|
||||
if config.Budget == 0 && config.Level == "" {
|
||||
// With the amount fully disabled, visibility is irrelevant. Restoring
|
||||
// includeThoughts alone would recreate thinkingConfig and let a
|
||||
// default-on model think again.
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig")
|
||||
return result, nil
|
||||
}
|
||||
if config.Level != "" {
|
||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
|
||||
}
|
||||
return applyAntigravityIncludeThoughts(result, body), nil
|
||||
}
|
||||
|
||||
// Only handle ModeLevel - budget conversion should be done by upper layer
|
||||
if config.Mode != thinking.ModeLevel {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
level := string(config.Level)
|
||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
|
||||
return applyAntigravityIncludeThoughts(result, body), nil
|
||||
}
|
||||
|
||||
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) {
|
||||
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig.thinkingLevel")
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_level")
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.thinking_budget")
|
||||
// Normalize includeThoughts field name and retain only documented booleans.
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.includeThoughts")
|
||||
result, _ = sjson.DeleteBytes(result, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||
|
||||
budget := config.Budget
|
||||
|
||||
// Apply Claude-specific constraints first to get the final budget value
|
||||
if isClaude && modelInfo != nil {
|
||||
budget, result = a.normalizeClaudeBudget(budget, result, modelInfo)
|
||||
// Check if the thinking amount was removed entirely. Summary visibility is
|
||||
// independent, so retain an explicit includeThoughts control if present.
|
||||
if budget == -2 {
|
||||
return applyAntigravityIncludeThoughts(result, body), nil
|
||||
}
|
||||
}
|
||||
|
||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||
return applyAntigravityIncludeThoughts(result, body), nil
|
||||
}
|
||||
|
||||
func applyAntigravityIncludeThoughts(result, original []byte) []byte {
|
||||
for _, path := range []string{
|
||||
"request.generationConfig.thinkingConfig.includeThoughts",
|
||||
"request.generationConfig.thinkingConfig.include_thoughts",
|
||||
} {
|
||||
switch value := gjson.GetBytes(original, path); value.Type {
|
||||
case gjson.True:
|
||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
||||
return result
|
||||
case gjson.False:
|
||||
result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false)
|
||||
return result
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeClaudeBudget applies Claude-specific constraints to thinking budget.
|
||||
//
|
||||
// It handles:
|
||||
// - Ensuring thinking budget < max_tokens
|
||||
// - Removing thinkingConfig if budget < minimum allowed
|
||||
//
|
||||
// Returns the normalized budget and updated payload.
|
||||
// Returns budget=-2 as a sentinel indicating thinkingConfig was removed entirely.
|
||||
func (a *Applier) normalizeClaudeBudget(budget int, payload []byte, modelInfo *registry.ModelInfo) (int, []byte) {
|
||||
if modelInfo == nil {
|
||||
return budget, payload
|
||||
}
|
||||
|
||||
// Get effective max tokens
|
||||
effectiveMax, setDefaultMax := a.effectiveMaxTokens(payload, modelInfo)
|
||||
if effectiveMax > 0 && budget >= effectiveMax {
|
||||
budget = effectiveMax - 1
|
||||
}
|
||||
|
||||
// Check minimum budget
|
||||
minBudget := 0
|
||||
if modelInfo.Thinking != nil {
|
||||
minBudget = modelInfo.Thinking.Min
|
||||
}
|
||||
if minBudget > 0 && budget >= 0 && budget < minBudget {
|
||||
// Budget is below minimum, remove thinking config entirely
|
||||
payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.thinkingConfig")
|
||||
return -2, payload
|
||||
}
|
||||
|
||||
// Set default max tokens if needed
|
||||
if setDefaultMax && effectiveMax > 0 {
|
||||
payload, _ = sjson.SetBytes(payload, "request.generationConfig.maxOutputTokens", effectiveMax)
|
||||
}
|
||||
|
||||
return budget, payload
|
||||
}
|
||||
|
||||
// effectiveMaxTokens returns the max tokens to cap thinking:
|
||||
// prefer request-provided maxOutputTokens; otherwise fall back to model default.
|
||||
// The boolean indicates whether the value came from the model default (and thus should be written back).
|
||||
func (a *Applier) effectiveMaxTokens(payload []byte, modelInfo *registry.ModelInfo) (max int, fromModel bool) {
|
||||
if maxTok := gjson.GetBytes(payload, "request.generationConfig.maxOutputTokens"); maxTok.Exists() && maxTok.Int() > 0 {
|
||||
return int(maxTok.Int()), false
|
||||
}
|
||||
if modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
|
||||
return modelInfo.MaxCompletionTokens, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
270
backend/internal/thinking/provider/claude/apply.go
Normal file
270
backend/internal/thinking/provider/claude/apply.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// Package claude implements thinking configuration scaffolding for Claude models.
|
||||
//
|
||||
// Claude models support two thinking control styles:
|
||||
// - Manual thinking: thinking.type="enabled" with thinking.budget_tokens (token budget)
|
||||
// - Adaptive thinking (Claude 4.6): thinking.type="adaptive" with output_config.effort (low/medium/high/max)
|
||||
//
|
||||
// Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5), while older models do not.
|
||||
// See: _bmad-output/planning-artifacts/architecture.md#Epic-6
|
||||
package claude
|
||||
|
||||
import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Applier implements thinking.ProviderApplier for Claude models.
|
||||
// This applier is stateless and holds no configuration.
|
||||
type Applier struct{}
|
||||
|
||||
// NewApplier creates a new Claude thinking applier.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
thinking.RegisterProvider("claude", NewApplier())
|
||||
}
|
||||
|
||||
// Apply applies thinking configuration to Claude request body.
|
||||
//
|
||||
// IMPORTANT: This method expects config to be pre-validated by thinking.ValidateConfig.
|
||||
// ValidateConfig handles:
|
||||
// - Mode conversion (Level→Budget, Auto→Budget)
|
||||
// - Budget clamping to model range
|
||||
// - ZeroAllowed constraint enforcement
|
||||
//
|
||||
// Apply processes:
|
||||
// - ModeBudget: manual thinking budget_tokens
|
||||
// - ModeLevel: adaptive thinking effort (Claude 4.6)
|
||||
// - ModeAuto: provider default adaptive/manual behavior
|
||||
// - ModeNone: disabled
|
||||
//
|
||||
// Expected output format when enabled:
|
||||
//
|
||||
// {
|
||||
// "thinking": {
|
||||
// "type": "enabled",
|
||||
// "budget_tokens": 16384
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected output format for adaptive:
|
||||
//
|
||||
// {
|
||||
// "thinking": {
|
||||
// "type": "adaptive"
|
||||
// },
|
||||
// "output_config": {
|
||||
// "effort": "high"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected output format when disabled:
|
||||
//
|
||||
// {
|
||||
// "thinking": {
|
||||
// "type": "disabled"
|
||||
// }
|
||||
// }
|
||||
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
if thinking.IsUserDefinedModel(modelInfo) {
|
||||
return applyCompatibleClaude(body, config)
|
||||
}
|
||||
if modelInfo.Thinking == nil {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
supportsAdaptive := modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0
|
||||
|
||||
switch config.Mode {
|
||||
case thinking.ModeNone:
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
// Summary display only applies to an active thinking block.
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.display")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
|
||||
case thinking.ModeLevel:
|
||||
// Adaptive thinking effort is only valid when the model advertises discrete levels.
|
||||
// (Claude 4.6 uses output_config.effort.)
|
||||
if supportsAdaptive && config.Level != "" {
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Fallback for non-adaptive Claude models: convert level to budget_tokens.
|
||||
if budget, ok := thinking.ConvertLevelToBudget(string(config.Level)); ok {
|
||||
config.Mode = thinking.ModeBudget
|
||||
config.Budget = budget
|
||||
config.Level = ""
|
||||
} else {
|
||||
return body, nil
|
||||
}
|
||||
fallthrough
|
||||
|
||||
case thinking.ModeBudget:
|
||||
// Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced).
|
||||
// Decide enabled/disabled based on budget value.
|
||||
if config.Budget == 0 {
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
|
||||
// Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint).
|
||||
result = a.normalizeClaudeBudget(result, config.Budget, modelInfo)
|
||||
return result, nil
|
||||
|
||||
case thinking.ModeAuto:
|
||||
// For Claude 4.6 models, auto maps to adaptive thinking with upstream defaults.
|
||||
if supportsAdaptive {
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
// Explicit effort is optional for adaptive thinking; omit it to allow upstream default.
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Legacy fallback: enable thinking without specifying budget_tokens.
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeClaudeBudget applies Claude-specific constraints to ensure max_tokens > budget_tokens.
|
||||
// Anthropic API requires this constraint; violating it returns a 400 error.
|
||||
func (a *Applier) normalizeClaudeBudget(body []byte, budgetTokens int, modelInfo *registry.ModelInfo) []byte {
|
||||
if budgetTokens <= 0 {
|
||||
return body
|
||||
}
|
||||
|
||||
// Ensure the request satisfies Claude constraints:
|
||||
// 1) Determine effective max_tokens (request overrides model default)
|
||||
// 2) If budget_tokens >= max_tokens, reduce budget_tokens to max_tokens-1
|
||||
// 3) If the adjusted budget falls below the model minimum, leave the request unchanged
|
||||
// 4) If max_tokens came from model default, write it back into the request
|
||||
|
||||
effectiveMax, setDefaultMax := a.effectiveMaxTokens(body, modelInfo)
|
||||
if setDefaultMax && effectiveMax > 0 {
|
||||
body, _ = sjson.SetBytes(body, "max_tokens", effectiveMax)
|
||||
}
|
||||
|
||||
// Compute the budget we would apply after enforcing budget_tokens < max_tokens.
|
||||
adjustedBudget := budgetTokens
|
||||
if effectiveMax > 0 && adjustedBudget >= effectiveMax {
|
||||
adjustedBudget = effectiveMax - 1
|
||||
}
|
||||
|
||||
minBudget := 0
|
||||
if modelInfo != nil && modelInfo.Thinking != nil {
|
||||
minBudget = modelInfo.Thinking.Min
|
||||
}
|
||||
if minBudget > 0 && adjustedBudget > 0 && adjustedBudget < minBudget {
|
||||
// If enforcing the max_tokens constraint would push the budget below the model minimum,
|
||||
// leave the request unchanged.
|
||||
return body
|
||||
}
|
||||
|
||||
if adjustedBudget != budgetTokens {
|
||||
body, _ = sjson.SetBytes(body, "thinking.budget_tokens", adjustedBudget)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// effectiveMaxTokens returns the max tokens to cap thinking:
|
||||
// prefer request-provided max_tokens; otherwise fall back to model default.
|
||||
// The boolean indicates whether the value came from the model default (and thus should be written back).
|
||||
func (a *Applier) effectiveMaxTokens(body []byte, modelInfo *registry.ModelInfo) (max int, fromModel bool) {
|
||||
if maxTok := gjson.GetBytes(body, "max_tokens"); maxTok.Exists() && maxTok.Int() > 0 {
|
||||
return int(maxTok.Int()), false
|
||||
}
|
||||
if modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
|
||||
return modelInfo.MaxCompletionTokens, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto && config.Mode != thinking.ModeLevel {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
switch config.Mode {
|
||||
case thinking.ModeNone:
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
// Summary display only applies to an active thinking block.
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.display")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
case thinking.ModeAuto:
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
case thinking.ModeLevel:
|
||||
// For user-defined models, interpret ModeLevel as Claude adaptive thinking effort.
|
||||
// Upstream is responsible for validating whether the target model supports it.
|
||||
if config.Level == "" {
|
||||
return body, nil
|
||||
}
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
|
||||
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
|
||||
result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
|
||||
return result, nil
|
||||
default:
|
||||
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
|
||||
result, _ = sjson.DeleteBytes(result, "output_config.effort")
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
120
backend/internal/thinking/provider/codex/apply.go
Normal file
120
backend/internal/thinking/provider/codex/apply.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// Package codex implements thinking configuration for Codex (OpenAI Responses API) models.
|
||||
//
|
||||
// Codex models use the reasoning.effort format with discrete levels
|
||||
// (low/medium/high). This is similar to OpenAI but uses nested field
|
||||
// "reasoning.effort" instead of "reasoning_effort".
|
||||
// See: _bmad-output/planning-artifacts/architecture.md#Epic-8
|
||||
package codex
|
||||
|
||||
import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Applier implements thinking.ProviderApplier for Codex models.
|
||||
//
|
||||
// Codex-specific behavior:
|
||||
// - Output format: reasoning.effort (string: low/medium/high/xhigh)
|
||||
// - Level-only mode: no numeric budget support
|
||||
// - Some models support ZeroAllowed (gpt-5.1, gpt-5.2)
|
||||
type Applier struct{}
|
||||
|
||||
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||
|
||||
// NewApplier creates a new Codex thinking applier.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
thinking.RegisterProvider("codex", NewApplier())
|
||||
}
|
||||
|
||||
// Apply applies thinking configuration to Codex request body.
|
||||
//
|
||||
// Expected output format:
|
||||
//
|
||||
// {
|
||||
// "reasoning": {
|
||||
// "effort": "high"
|
||||
// }
|
||||
// }
|
||||
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
if thinking.IsUserDefinedModel(modelInfo) {
|
||||
return applyCompatibleCodex(body, config)
|
||||
}
|
||||
if modelInfo.Thinking == nil {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Only handle ModeLevel and ModeNone; other modes pass through unchanged.
|
||||
if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
if config.Mode == thinking.ModeLevel {
|
||||
result, _ := sjson.SetBytes(body, "reasoning.effort", string(config.Level))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
effort := ""
|
||||
support := modelInfo.Thinking
|
||||
if config.Budget == 0 {
|
||||
if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) {
|
||||
effort = string(thinking.LevelNone)
|
||||
}
|
||||
}
|
||||
if effort == "" && config.Level != "" {
|
||||
effort = string(config.Level)
|
||||
}
|
||||
if effort == "" && len(support.Levels) > 0 {
|
||||
effort = support.Levels[0]
|
||||
}
|
||||
if effort == "" {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
result, _ := sjson.SetBytes(body, "reasoning.effort", effort)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyCompatibleCodex(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
var effort string
|
||||
switch config.Mode {
|
||||
case thinking.ModeLevel:
|
||||
if config.Level == "" {
|
||||
return body, nil
|
||||
}
|
||||
effort = string(config.Level)
|
||||
case thinking.ModeNone:
|
||||
effort = string(thinking.LevelNone)
|
||||
if config.Level != "" {
|
||||
effort = string(config.Level)
|
||||
}
|
||||
case thinking.ModeAuto:
|
||||
// Auto mode for user-defined models: pass through as "auto"
|
||||
effort = string(thinking.LevelAuto)
|
||||
case thinking.ModeBudget:
|
||||
// Budget mode: convert budget to level using threshold mapping
|
||||
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
|
||||
if !ok {
|
||||
return body, nil
|
||||
}
|
||||
effort = level
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
|
||||
result, _ := sjson.SetBytes(body, "reasoning.effort", effort)
|
||||
return result, nil
|
||||
}
|
||||
182
backend/internal/thinking/provider/gemini/apply.go
Normal file
182
backend/internal/thinking/provider/gemini/apply.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Package gemini implements thinking configuration for Gemini models.
|
||||
//
|
||||
// Gemini models have two formats:
|
||||
// - Gemini 2.5: Uses thinkingBudget (numeric)
|
||||
// - Gemini 3.x: Uses thinkingLevel (string: minimal/low/medium/high)
|
||||
// or thinkingBudget=-1 for auto/dynamic mode
|
||||
//
|
||||
// Output format is determined by ThinkingConfig.Mode and ThinkingSupport.Levels:
|
||||
// - ModeAuto: Always uses thinkingBudget=-1 (both Gemini 2.5 and 3.x)
|
||||
// - len(Levels) > 0: Uses thinkingLevel (Gemini 3.x discrete levels)
|
||||
// - len(Levels) == 0: Uses thinkingBudget (Gemini 2.5)
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Applier applies thinking configuration for Gemini models.
|
||||
//
|
||||
// Gemini-specific behavior:
|
||||
// - Gemini 2.5: thinkingBudget format, flash series supports ZeroAllowed
|
||||
// - Gemini 3.x: thinkingLevel format, disable by removing thinkingConfig when zero is allowed
|
||||
// - Use ThinkingSupport.Levels to decide output format
|
||||
type Applier struct{}
|
||||
|
||||
// NewApplier creates a new Gemini thinking applier.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
thinking.RegisterProvider("gemini", NewApplier())
|
||||
}
|
||||
|
||||
// Apply applies thinking configuration to Gemini request body.
|
||||
//
|
||||
// Expected output format (Gemini 2.5):
|
||||
//
|
||||
// {
|
||||
// "generationConfig": {
|
||||
// "thinkingConfig": {
|
||||
// "thinkingBudget": 8192,
|
||||
// "includeThoughts": true
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected output format (Gemini 3.x):
|
||||
//
|
||||
// {
|
||||
// "generationConfig": {
|
||||
// "thinkingConfig": {
|
||||
// "thinkingLevel": "high",
|
||||
// "includeThoughts": true
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
if thinking.IsUserDefinedModel(modelInfo) {
|
||||
return a.applyCompatible(body, config)
|
||||
}
|
||||
if modelInfo.Thinking == nil {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
// Choose format based on config.Mode and model capabilities:
|
||||
// - ModeLevel: use Level format (validation will reject unsupported levels)
|
||||
// - ModeNone: use Level format if model has Levels, else Budget format
|
||||
// - ModeBudget/ModeAuto: use Budget format
|
||||
switch config.Mode {
|
||||
case thinking.ModeLevel:
|
||||
return a.applyLevelFormat(body, config)
|
||||
case thinking.ModeNone:
|
||||
// ModeNone: route based on model capability (has Levels or not)
|
||||
if len(modelInfo.Thinking.Levels) > 0 {
|
||||
return a.applyLevelFormat(body, config)
|
||||
}
|
||||
return a.applyBudgetFormat(body, config)
|
||||
default:
|
||||
return a.applyBudgetFormat(body, config)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
if config.Mode == thinking.ModeAuto {
|
||||
return a.applyBudgetFormat(body, config)
|
||||
}
|
||||
|
||||
if config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != "") {
|
||||
return a.applyLevelFormat(body, config)
|
||||
}
|
||||
|
||||
return a.applyBudgetFormat(body, config)
|
||||
}
|
||||
|
||||
func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
// ModeNone semantics:
|
||||
// - ModeNone + Budget=0: remove the thinking amount configuration.
|
||||
// - ModeNone + Budget>0: clamp to the model's lowest supported amount.
|
||||
// Summary visibility remains independent and is restored only when explicitly set.
|
||||
|
||||
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingBudget")
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget")
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level")
|
||||
// Normalize includeThoughts field name and retain only documented booleans.
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.includeThoughts")
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
|
||||
|
||||
if config.Mode == thinking.ModeNone {
|
||||
if config.Budget == 0 && config.Level == "" {
|
||||
// With the amount fully disabled, visibility is irrelevant. Restoring
|
||||
// includeThoughts alone would recreate thinkingConfig and let a
|
||||
// default-on model think again.
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig")
|
||||
return result, nil
|
||||
}
|
||||
if config.Level != "" {
|
||||
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
|
||||
}
|
||||
return applyGeminiIncludeThoughts(result, body), nil
|
||||
}
|
||||
|
||||
// Only handle ModeLevel - budget conversion should be done by upper layer
|
||||
if config.Mode != thinking.ModeLevel {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
level := string(config.Level)
|
||||
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level)
|
||||
return applyGeminiIncludeThoughts(result, body), nil
|
||||
}
|
||||
|
||||
func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
// Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
|
||||
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.thinkingLevel")
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_level")
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.thinking_budget")
|
||||
// Normalize includeThoughts field name and retain only documented booleans.
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.includeThoughts")
|
||||
result, _ = sjson.DeleteBytes(result, "generationConfig.thinkingConfig.include_thoughts")
|
||||
|
||||
budget := config.Budget
|
||||
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||
return applyGeminiIncludeThoughts(result, body), nil
|
||||
}
|
||||
|
||||
func applyGeminiIncludeThoughts(result, original []byte) []byte {
|
||||
for _, path := range []string{
|
||||
"generationConfig.thinkingConfig.includeThoughts",
|
||||
"generationConfig.thinkingConfig.include_thoughts",
|
||||
} {
|
||||
switch value := gjson.GetBytes(original, path); value.Type {
|
||||
case gjson.True:
|
||||
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true)
|
||||
return result
|
||||
case gjson.False:
|
||||
result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false)
|
||||
return result
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
178
backend/internal/thinking/provider/interactions/apply.go
Normal file
178
backend/internal/thinking/provider/interactions/apply.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// Package interactions applies native Interactions thinking configuration.
|
||||
package interactions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Applier implements thinking.ProviderApplier for the native Interactions API.
|
||||
type Applier struct{}
|
||||
|
||||
// NewApplier creates a new Interactions thinking applier.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
thinking.RegisterProvider("interactions", NewApplier())
|
||||
}
|
||||
|
||||
// Apply writes thinking configuration using native Interactions generation_config fields.
|
||||
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
|
||||
return body, nil
|
||||
}
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
result := stripInteractionsThinkingFields(body)
|
||||
switch config.Mode {
|
||||
case thinking.ModeLevel:
|
||||
return applyInteractionsLevel(result, body, string(config.Level), modelInfo), nil
|
||||
case thinking.ModeBudget:
|
||||
return applyInteractionsBudget(result, body, config.Budget, modelInfo), nil
|
||||
case thinking.ModeAuto:
|
||||
return setInteractionsThinkingSummaries(result, body), nil
|
||||
case thinking.ModeNone:
|
||||
return applyInteractionsNone(result, body, config, modelInfo), nil
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
func applyInteractionsBudget(result, original []byte, budget int, modelInfo *registry.ModelInfo) []byte {
|
||||
level, ok := thinking.ConvertBudgetToLevel(budget)
|
||||
if !ok {
|
||||
return setInteractionsThinkingSummaries(result, original)
|
||||
}
|
||||
switch level {
|
||||
case string(thinking.LevelNone), string(thinking.LevelAuto):
|
||||
// Thinking amount and summary visibility are independent. Interactions has
|
||||
// no wire-level "none" thinking level, so preserve only explicit summary
|
||||
// intent and otherwise let the target model use its documented default.
|
||||
return setInteractionsThinkingSummaries(result, original)
|
||||
default:
|
||||
return applyInteractionsLevel(result, original, level, modelInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func applyInteractionsLevel(result, original []byte, level string, modelInfo *registry.ModelInfo) []byte {
|
||||
level = normalizeInteractionsLevel(level, modelInfo)
|
||||
if level != "" {
|
||||
result, _ = sjson.SetBytes(result, "generation_config.thinking_level", level)
|
||||
}
|
||||
return setInteractionsThinkingSummaries(result, original)
|
||||
}
|
||||
|
||||
func applyInteractionsNone(result, original []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) []byte {
|
||||
if config.Level != "" {
|
||||
return applyInteractionsLevel(result, original, string(config.Level), modelInfo)
|
||||
}
|
||||
if config.Budget > 0 {
|
||||
return applyInteractionsBudget(result, original, config.Budget, modelInfo)
|
||||
}
|
||||
// With the amount fully disabled, visibility is irrelevant. Restoring
|
||||
// thinking_summaries alone could make a default-on model reason and return a
|
||||
// summary despite the explicit none override.
|
||||
return result
|
||||
}
|
||||
|
||||
func stripInteractionsThinkingFields(body []byte) []byte {
|
||||
result := body
|
||||
for _, path := range []string{
|
||||
"generation_config.thinking_level",
|
||||
"generation_config.thinkingLevel",
|
||||
"generation_config.thinking_budget",
|
||||
"generation_config.thinkingBudget",
|
||||
"generation_config.thinking_summaries",
|
||||
"generation_config.thinkingSummaries",
|
||||
"generation_config.thinking_config",
|
||||
"generation_config.thinkingConfig",
|
||||
"generationConfig.thinkingLevel",
|
||||
"generationConfig.thinking_level",
|
||||
"generationConfig.thinkingBudget",
|
||||
"generationConfig.thinking_budget",
|
||||
"generationConfig.thinkingSummaries",
|
||||
"generationConfig.thinking_summaries",
|
||||
"generationConfig.thinkingConfig",
|
||||
} {
|
||||
result, _ = sjson.DeleteBytes(result, path)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func setInteractionsThinkingSummaries(result, original []byte) []byte {
|
||||
if value, okValue := originalInteractionsThinkingSummaries(original); okValue {
|
||||
result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value)
|
||||
return result
|
||||
}
|
||||
if includeThoughts, okValue := originalInteractionsIncludeThoughts(original); okValue {
|
||||
value := "none"
|
||||
if includeThoughts {
|
||||
value = "auto"
|
||||
}
|
||||
result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func originalInteractionsThinkingSummaries(body []byte) (string, bool) {
|
||||
for _, path := range []string{
|
||||
"generation_config.thinking_summaries",
|
||||
"generation_config.thinkingSummaries",
|
||||
} {
|
||||
value := gjson.GetBytes(body, path)
|
||||
if value.Type != gjson.String {
|
||||
continue
|
||||
}
|
||||
switch normalized := strings.ToLower(strings.TrimSpace(value.String())); normalized {
|
||||
case "auto", "none":
|
||||
return normalized, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func originalInteractionsIncludeThoughts(body []byte) (bool, bool) {
|
||||
for _, path := range []string{
|
||||
"generation_config.thinking_config.include_thoughts",
|
||||
"generation_config.thinking_config.includeThoughts",
|
||||
"generation_config.thinkingConfig.include_thoughts",
|
||||
"generation_config.thinkingConfig.includeThoughts",
|
||||
} {
|
||||
switch value := gjson.GetBytes(body, path); value.Type {
|
||||
case gjson.True:
|
||||
return true, true
|
||||
case gjson.False:
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func normalizeInteractionsLevel(level string, modelInfo *registry.ModelInfo) string {
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
if level == "" || level == string(thinking.LevelNone) || level == string(thinking.LevelAuto) {
|
||||
return ""
|
||||
}
|
||||
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 {
|
||||
for _, candidate := range modelInfo.Thinking.Levels {
|
||||
if strings.EqualFold(candidate, level) {
|
||||
return strings.ToLower(candidate)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(modelInfo.Thinking.Levels[len(modelInfo.Thinking.Levels)-1])
|
||||
}
|
||||
switch level {
|
||||
case string(thinking.LevelMax), string(thinking.LevelXHigh):
|
||||
return string(thinking.LevelHigh)
|
||||
default:
|
||||
return level
|
||||
}
|
||||
}
|
||||
168
backend/internal/thinking/provider/kimi/apply.go
Normal file
168
backend/internal/thinking/provider/kimi/apply.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// Package kimi implements thinking configuration for Kimi (Moonshot AI) models.
|
||||
//
|
||||
// Kimi models use a native thinking object for both enabled and disabled thinking.
|
||||
// The top-level reasoning_effort field is accepted only as a legacy input by the
|
||||
// unified extraction layer and is removed from the final Kimi payload.
|
||||
package kimi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Applier implements thinking.ProviderApplier for Kimi models.
|
||||
//
|
||||
// Kimi-specific behavior:
|
||||
// - Enabled thinking: thinking.type="enabled" + thinking.effort=<level>
|
||||
// - Disabled thinking: thinking.type="disabled"
|
||||
// - Supports budget-to-level conversion
|
||||
// - Preserves existing thinking.keep when enabling or changing effort
|
||||
type Applier struct{}
|
||||
|
||||
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||
|
||||
// NewApplier creates a new Kimi thinking applier.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
thinking.RegisterProvider("kimi", NewApplier())
|
||||
}
|
||||
|
||||
// Apply applies thinking configuration to Kimi request body.
|
||||
//
|
||||
// Expected output format (enabled):
|
||||
//
|
||||
// {
|
||||
// "thinking": {
|
||||
// "type": "enabled",
|
||||
// "effort": "high"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Expected output format (disabled):
|
||||
//
|
||||
// {
|
||||
// "thinking": {
|
||||
// "type": "disabled"
|
||||
// }
|
||||
// }
|
||||
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
if thinking.IsUserDefinedModel(modelInfo) {
|
||||
return applyCompatibleKimi(body, config)
|
||||
}
|
||||
if modelInfo.Thinking == nil {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
var effort string
|
||||
switch config.Mode {
|
||||
case thinking.ModeLevel:
|
||||
if config.Level == "" {
|
||||
return body, nil
|
||||
}
|
||||
effort = string(config.Level)
|
||||
case thinking.ModeNone:
|
||||
// Respect clamped fallback level for models that cannot disable thinking.
|
||||
if config.Level != "" && config.Level != thinking.LevelNone {
|
||||
effort = string(config.Level)
|
||||
break
|
||||
}
|
||||
// Kimi requires explicit disabled thinking object.
|
||||
return applyDisabledThinking(body)
|
||||
case thinking.ModeBudget:
|
||||
// Convert budget to level using threshold mapping
|
||||
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
|
||||
if !ok {
|
||||
return body, nil
|
||||
}
|
||||
effort = level
|
||||
case thinking.ModeAuto:
|
||||
// Auto mode maps to "auto" effort
|
||||
effort = string(thinking.LevelAuto)
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if effort == "" {
|
||||
return body, nil
|
||||
}
|
||||
return applyEnabledThinking(body, effort)
|
||||
}
|
||||
|
||||
// applyCompatibleKimi applies thinking config for user-defined Kimi models.
|
||||
func applyCompatibleKimi(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
var effort string
|
||||
switch config.Mode {
|
||||
case thinking.ModeLevel:
|
||||
if config.Level == "" {
|
||||
return body, nil
|
||||
}
|
||||
effort = string(config.Level)
|
||||
case thinking.ModeNone:
|
||||
if config.Level == "" || config.Level == thinking.LevelNone {
|
||||
return applyDisabledThinking(body)
|
||||
}
|
||||
if config.Level != "" {
|
||||
effort = string(config.Level)
|
||||
}
|
||||
case thinking.ModeAuto:
|
||||
effort = string(thinking.LevelAuto)
|
||||
case thinking.ModeBudget:
|
||||
// Convert budget to level
|
||||
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
|
||||
if !ok {
|
||||
return body, nil
|
||||
}
|
||||
effort = level
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
|
||||
return applyEnabledThinking(body, effort)
|
||||
}
|
||||
|
||||
func applyEnabledThinking(body []byte, effort string) ([]byte, error) {
|
||||
result, errDeleteLegacyEffort := sjson.DeleteBytes(body, "reasoning_effort")
|
||||
if errDeleteLegacyEffort != nil {
|
||||
return body, fmt.Errorf("kimi thinking: failed to clear reasoning_effort: %w", errDeleteLegacyEffort)
|
||||
}
|
||||
result, errSetType := sjson.SetBytes(result, "thinking.type", "enabled")
|
||||
if errSetType != nil {
|
||||
return body, fmt.Errorf("kimi thinking: failed to set thinking.type: %w", errSetType)
|
||||
}
|
||||
result, errSetEffort := sjson.SetBytes(result, "thinking.effort", effort)
|
||||
if errSetEffort != nil {
|
||||
return body, fmt.Errorf("kimi thinking: failed to set thinking.effort: %w", errSetEffort)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyDisabledThinking(body []byte) ([]byte, error) {
|
||||
result, errDeleteThinking := sjson.DeleteBytes(body, "thinking")
|
||||
if errDeleteThinking != nil {
|
||||
return body, fmt.Errorf("kimi thinking: failed to clear thinking object: %w", errDeleteThinking)
|
||||
}
|
||||
result, errDeleteEffort := sjson.DeleteBytes(result, "reasoning_effort")
|
||||
if errDeleteEffort != nil {
|
||||
return body, fmt.Errorf("kimi thinking: failed to clear reasoning_effort: %w", errDeleteEffort)
|
||||
}
|
||||
result, errSetType := sjson.SetBytes(result, "thinking.type", "disabled")
|
||||
if errSetType != nil {
|
||||
return body, fmt.Errorf("kimi thinking: failed to set thinking.type: %w", errSetType)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
117
backend/internal/thinking/provider/openai/apply.go
Normal file
117
backend/internal/thinking/provider/openai/apply.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Package openai implements thinking configuration for OpenAI/Codex models.
|
||||
//
|
||||
// OpenAI models use the reasoning_effort format with discrete levels
|
||||
// (low/medium/high). Some models support xhigh and none levels.
|
||||
// See: _bmad-output/planning-artifacts/architecture.md#Epic-8
|
||||
package openai
|
||||
|
||||
import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Applier implements thinking.ProviderApplier for OpenAI models.
|
||||
//
|
||||
// OpenAI-specific behavior:
|
||||
// - Output format: reasoning_effort (string: low/medium/high/xhigh)
|
||||
// - Level-only mode: no numeric budget support
|
||||
// - Some models support ZeroAllowed (gpt-5.1, gpt-5.2)
|
||||
type Applier struct{}
|
||||
|
||||
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||
|
||||
// NewApplier creates a new OpenAI thinking applier.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
thinking.RegisterProvider("openai", NewApplier())
|
||||
}
|
||||
|
||||
// Apply applies thinking configuration to OpenAI request body.
|
||||
//
|
||||
// Expected output format:
|
||||
//
|
||||
// {
|
||||
// "reasoning_effort": "high"
|
||||
// }
|
||||
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
|
||||
if thinking.IsUserDefinedModel(modelInfo) {
|
||||
return applyCompatibleOpenAI(body, config)
|
||||
}
|
||||
if modelInfo.Thinking == nil {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Only handle ModeLevel and ModeNone; other modes pass through unchanged.
|
||||
if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
if config.Mode == thinking.ModeLevel {
|
||||
result, _ := sjson.SetBytes(body, "reasoning_effort", string(config.Level))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
effort := ""
|
||||
support := modelInfo.Thinking
|
||||
if config.Budget == 0 {
|
||||
if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) {
|
||||
effort = string(thinking.LevelNone)
|
||||
}
|
||||
}
|
||||
if effort == "" && config.Level != "" {
|
||||
effort = string(config.Level)
|
||||
}
|
||||
if effort == "" && len(support.Levels) > 0 {
|
||||
effort = support.Levels[0]
|
||||
}
|
||||
if effort == "" {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
body = []byte(`{}`)
|
||||
}
|
||||
|
||||
var effort string
|
||||
switch config.Mode {
|
||||
case thinking.ModeLevel:
|
||||
if config.Level == "" {
|
||||
return body, nil
|
||||
}
|
||||
effort = string(config.Level)
|
||||
case thinking.ModeNone:
|
||||
effort = string(thinking.LevelNone)
|
||||
if config.Level != "" {
|
||||
effort = string(config.Level)
|
||||
}
|
||||
case thinking.ModeAuto:
|
||||
// Auto mode for user-defined models: pass through as "auto"
|
||||
effort = string(thinking.LevelAuto)
|
||||
case thinking.ModeBudget:
|
||||
// Budget mode: convert budget to level using threshold mapping
|
||||
level, ok := thinking.ConvertBudgetToLevel(config.Budget)
|
||||
if !ok {
|
||||
return body, nil
|
||||
}
|
||||
effort = level
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
|
||||
result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
|
||||
return result, nil
|
||||
}
|
||||
26
backend/internal/thinking/provider/xai/apply.go
Normal file
26
backend/internal/thinking/provider/xai/apply.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Package xai implements thinking configuration for xAI Grok Responses API models.
|
||||
//
|
||||
// xAI models use the OpenAI Responses API compatible reasoning.effort format
|
||||
// with discrete levels.
|
||||
package xai
|
||||
|
||||
import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex"
|
||||
)
|
||||
|
||||
// Applier implements thinking.ProviderApplier for xAI models.
|
||||
type Applier struct {
|
||||
codex.Applier
|
||||
}
|
||||
|
||||
var _ thinking.ProviderApplier = (*Applier)(nil)
|
||||
|
||||
// NewApplier creates a new xAI thinking applier.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
thinking.RegisterProvider("xai", NewApplier())
|
||||
}
|
||||
74
backend/internal/thinking/strip.go
Normal file
74
backend/internal/thinking/strip.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Package thinking provides unified thinking configuration processing.
|
||||
package thinking
|
||||
|
||||
import (
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// StripThinkingConfig removes thinking configuration fields from request body.
|
||||
//
|
||||
// This function is used when a model doesn't support thinking but the request
|
||||
// contains thinking configuration. The configuration is silently removed to
|
||||
// prevent upstream API errors.
|
||||
//
|
||||
// Parameters:
|
||||
// - body: Original request body JSON
|
||||
// - provider: Provider name (determines which fields to strip)
|
||||
//
|
||||
// Returns:
|
||||
// - Modified request body JSON with thinking configuration removed
|
||||
// - Original body is returned unchanged if:
|
||||
// - body is empty or invalid JSON
|
||||
// - provider is unknown
|
||||
// - no thinking configuration found
|
||||
func StripThinkingConfig(body []byte, provider string) []byte {
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return body
|
||||
}
|
||||
|
||||
var paths []string
|
||||
switch provider {
|
||||
case "claude":
|
||||
paths = []string{"thinking", "output_config.effort"}
|
||||
case "gemini":
|
||||
paths = []string{"generationConfig.thinkingConfig"}
|
||||
case "antigravity":
|
||||
paths = []string{"request.generationConfig.thinkingConfig"}
|
||||
case "interactions":
|
||||
paths = []string{
|
||||
"generation_config.thinking_level",
|
||||
"generation_config.thinkingLevel",
|
||||
"generation_config.thinking_budget",
|
||||
"generation_config.thinkingBudget",
|
||||
"generation_config.thinking_summaries",
|
||||
"generation_config.thinkingSummaries",
|
||||
"generation_config.thinking_config",
|
||||
"generation_config.thinkingConfig",
|
||||
}
|
||||
case "openai":
|
||||
paths = []string{"reasoning_effort", "reasoning"}
|
||||
case "kimi":
|
||||
paths = []string{
|
||||
"reasoning_effort",
|
||||
"thinking",
|
||||
}
|
||||
case "codex", "xai":
|
||||
paths = []string{"reasoning"}
|
||||
default:
|
||||
return body
|
||||
}
|
||||
|
||||
result := body
|
||||
for _, path := range paths {
|
||||
result, _ = sjson.DeleteBytes(result, path)
|
||||
}
|
||||
|
||||
// Avoid leaving an empty output_config object for Claude when effort was the only field.
|
||||
if provider == "claude" {
|
||||
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
|
||||
result, _ = sjson.DeleteBytes(result, "output_config")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
148
backend/internal/thinking/suffix.go
Normal file
148
backend/internal/thinking/suffix.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// Package thinking provides unified thinking configuration processing.
|
||||
//
|
||||
// This file implements suffix parsing functionality for extracting
|
||||
// thinking configuration from model names in the format model(value).
|
||||
package thinking
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseSuffix extracts thinking suffix from a model name.
|
||||
//
|
||||
// The suffix format is: model-name(value)
|
||||
// Examples:
|
||||
// - "claude-sonnet-4-5(16384)" -> ModelName="claude-sonnet-4-5", RawSuffix="16384"
|
||||
// - "gpt-5.2(high)" -> ModelName="gpt-5.2", RawSuffix="high"
|
||||
// - "gemini-2.5-pro" -> ModelName="gemini-2.5-pro", HasSuffix=false
|
||||
//
|
||||
// This function only extracts the suffix; it does not validate or interpret
|
||||
// the suffix content. Use ParseNumericSuffix, ParseLevelSuffix, etc. for
|
||||
// content interpretation.
|
||||
func ParseSuffix(model string) SuffixResult {
|
||||
// Find the last opening parenthesis
|
||||
lastOpen := strings.LastIndex(model, "(")
|
||||
if lastOpen == -1 {
|
||||
return SuffixResult{ModelName: model, HasSuffix: false}
|
||||
}
|
||||
|
||||
// Check if the string ends with a closing parenthesis
|
||||
if !strings.HasSuffix(model, ")") {
|
||||
return SuffixResult{ModelName: model, HasSuffix: false}
|
||||
}
|
||||
|
||||
// Extract components
|
||||
modelName := model[:lastOpen]
|
||||
rawSuffix := model[lastOpen+1 : len(model)-1]
|
||||
|
||||
return SuffixResult{
|
||||
ModelName: modelName,
|
||||
HasSuffix: true,
|
||||
RawSuffix: rawSuffix,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseNumericSuffix attempts to parse a raw suffix as a numeric budget value.
|
||||
//
|
||||
// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as an integer.
|
||||
// Only non-negative integers are considered valid numeric suffixes.
|
||||
//
|
||||
// Platform note: The budget value uses Go's int type, which is 32-bit on 32-bit
|
||||
// systems and 64-bit on 64-bit systems. Values exceeding the platform's int range
|
||||
// will return ok=false.
|
||||
//
|
||||
// Leading zeros are accepted: "08192" parses as 8192.
|
||||
//
|
||||
// Examples:
|
||||
// - "8192" -> budget=8192, ok=true
|
||||
// - "0" -> budget=0, ok=true (represents ModeNone)
|
||||
// - "08192" -> budget=8192, ok=true (leading zeros accepted)
|
||||
// - "-1" -> budget=0, ok=false (negative numbers are not valid numeric suffixes)
|
||||
// - "high" -> budget=0, ok=false (not a number)
|
||||
// - "9223372036854775808" -> budget=0, ok=false (overflow on 64-bit systems)
|
||||
//
|
||||
// For special handling of -1 as auto mode, use ParseSpecialSuffix instead.
|
||||
func ParseNumericSuffix(rawSuffix string) (budget int, ok bool) {
|
||||
if rawSuffix == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(rawSuffix)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Negative numbers are not valid numeric suffixes
|
||||
// -1 should be handled by special value parsing as "auto"
|
||||
if value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return value, true
|
||||
}
|
||||
|
||||
// ParseSpecialSuffix attempts to parse a raw suffix as a special thinking mode value.
|
||||
//
|
||||
// This function handles special strings that represent a change in thinking mode:
|
||||
// - "none" -> ModeNone (disables thinking)
|
||||
// - "auto" -> ModeAuto (automatic/dynamic thinking)
|
||||
// - "-1" -> ModeAuto (numeric representation of auto mode)
|
||||
//
|
||||
// String values are case-insensitive.
|
||||
func ParseSpecialSuffix(rawSuffix string) (mode ThinkingMode, ok bool) {
|
||||
if rawSuffix == "" {
|
||||
return ModeBudget, false
|
||||
}
|
||||
|
||||
// Case-insensitive matching
|
||||
switch strings.ToLower(rawSuffix) {
|
||||
case "none":
|
||||
return ModeNone, true
|
||||
case "auto", "-1":
|
||||
return ModeAuto, true
|
||||
default:
|
||||
return ModeBudget, false
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevelSuffix attempts to parse a raw suffix as a discrete thinking level.
|
||||
//
|
||||
// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as a level.
|
||||
// Only discrete effort levels are valid: minimal, low, medium, high, xhigh, max.
|
||||
// Level matching is case-insensitive.
|
||||
//
|
||||
// Special values (none, auto) are NOT handled by this function; use ParseSpecialSuffix
|
||||
// instead. This separation allows callers to prioritize special value handling.
|
||||
//
|
||||
// Examples:
|
||||
// - "high" -> level=LevelHigh, ok=true
|
||||
// - "HIGH" -> level=LevelHigh, ok=true (case insensitive)
|
||||
// - "medium" -> level=LevelMedium, ok=true
|
||||
// - "none" -> level="", ok=false (special value, use ParseSpecialSuffix)
|
||||
// - "auto" -> level="", ok=false (special value, use ParseSpecialSuffix)
|
||||
// - "8192" -> level="", ok=false (numeric, use ParseNumericSuffix)
|
||||
// - "ultra" -> level="", ok=false (unknown level)
|
||||
func ParseLevelSuffix(rawSuffix string) (level ThinkingLevel, ok bool) {
|
||||
if rawSuffix == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Case-insensitive matching
|
||||
switch strings.ToLower(rawSuffix) {
|
||||
case "minimal":
|
||||
return LevelMinimal, true
|
||||
case "low":
|
||||
return LevelLow, true
|
||||
case "medium":
|
||||
return LevelMedium, true
|
||||
case "high":
|
||||
return LevelHigh, true
|
||||
case "xhigh":
|
||||
return LevelXHigh, true
|
||||
case "max":
|
||||
return LevelMax, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
512
backend/internal/thinking/summary.go
Normal file
512
backend/internal/thinking/summary.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
package thinking
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// SummaryMode represents whether the client explicitly requested reasoning summaries.
|
||||
type SummaryMode int
|
||||
|
||||
const (
|
||||
SummaryUnspecified SummaryMode = iota
|
||||
SummaryDisabled
|
||||
SummaryEnabled
|
||||
)
|
||||
|
||||
// SummaryConfig is the provider-neutral reasoning-summary visibility intent.
|
||||
// Detail preserves protocols that distinguish auto, concise, and detailed summaries.
|
||||
type SummaryConfig struct {
|
||||
Mode SummaryMode
|
||||
Detail string
|
||||
}
|
||||
|
||||
// ExtractSummaryConfig reads protocol-specific summary visibility intent.
|
||||
//
|
||||
// OpenAI Chat is the one protocol where effort implies summaries: chat
|
||||
// completions has no summary field of its own, and clients that send
|
||||
// reasoning_effort have always received reasoning summaries here, so treating a
|
||||
// non-none effort as an explicit request preserves that contract. Every other
|
||||
// protocol carries a dedicated summary field, so effort alone means nothing.
|
||||
func ExtractSummaryConfig(body []byte, format string) SummaryConfig {
|
||||
normalized := strings.ToLower(strings.TrimSpace(format))
|
||||
// Check the format first so unsupported targets skip whole-body validation.
|
||||
if !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return SummaryConfig{}
|
||||
}
|
||||
|
||||
switch normalized {
|
||||
case "openai":
|
||||
if config, ok := extractOpenAIExplicitSummaryConfig(body); ok {
|
||||
return config
|
||||
}
|
||||
if effort := gjson.GetBytes(body, "reasoning_effort"); effort.Type == gjson.String {
|
||||
value := strings.ToLower(strings.TrimSpace(effort.String()))
|
||||
if value == "" {
|
||||
return SummaryConfig{}
|
||||
}
|
||||
if value == "none" {
|
||||
return SummaryConfig{Mode: SummaryDisabled}
|
||||
}
|
||||
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}
|
||||
}
|
||||
case "openai-response", "codex":
|
||||
if config, ok := responsesSummaryConfig(body, "reasoning.summary"); ok {
|
||||
return config
|
||||
}
|
||||
if config, ok := responsesSummaryConfig(body, "reasoning.generate_summary"); ok {
|
||||
return config
|
||||
}
|
||||
case "claude":
|
||||
// Anthropic only accepts display alongside active adaptive/manual thinking.
|
||||
if !claudeThinkingAcceptsDisplay(body) {
|
||||
return SummaryConfig{}
|
||||
}
|
||||
if config, ok := claudeSummaryConfig(body, "thinking.display"); ok {
|
||||
return config
|
||||
}
|
||||
case "gemini":
|
||||
if config, ok := firstSummaryBoolConfig(body, []string{
|
||||
"generationConfig.thinkingConfig.includeThoughts",
|
||||
"generationConfig.thinkingConfig.include_thoughts",
|
||||
"generation_config.thinking_config.include_thoughts",
|
||||
"generation_config.thinking_config.includeThoughts",
|
||||
}); ok {
|
||||
return config
|
||||
}
|
||||
case "antigravity":
|
||||
if config, ok := firstSummaryBoolConfig(body, []string{
|
||||
"request.generationConfig.thinkingConfig.includeThoughts",
|
||||
"request.generationConfig.thinkingConfig.include_thoughts",
|
||||
"request.generationConfig.thinking_config.includeThoughts",
|
||||
"request.generationConfig.thinking_config.include_thoughts",
|
||||
}); ok {
|
||||
return config
|
||||
}
|
||||
case "interactions":
|
||||
for _, path := range []string{
|
||||
"generation_config.thinking_summaries",
|
||||
"generation_config.thinkingSummaries",
|
||||
} {
|
||||
if config, ok := interactionsSummaryConfig(body, path); ok {
|
||||
return config
|
||||
}
|
||||
}
|
||||
// Existing Interactions translators accept the OpenAI-style top-level
|
||||
// compatibility object. Keep the official generation_config selector
|
||||
// authoritative when both are present.
|
||||
if config, ok := interactionsSummaryConfig(body, "reasoning.summary"); ok {
|
||||
return config
|
||||
}
|
||||
if config, ok := firstSummaryBoolConfig(body, []string{
|
||||
"generation_config.thinking_config.include_thoughts",
|
||||
"generation_config.thinking_config.includeThoughts",
|
||||
"generation_config.thinkingConfig.include_thoughts",
|
||||
"generation_config.thinkingConfig.includeThoughts",
|
||||
}); ok {
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
return SummaryConfig{}
|
||||
}
|
||||
|
||||
// ExtractExplicitSummaryConfig reads only explicit visibility controls from a
|
||||
// provider payload. Unlike ExtractSummaryConfig, OpenAI Chat reasoning_effort
|
||||
// is not treated as a summary proxy. This lets executor post-processing tell
|
||||
// whether a request normalizer retained or removed the translated target field.
|
||||
func ExtractExplicitSummaryConfig(body []byte, format string) SummaryConfig {
|
||||
normalized := strings.ToLower(strings.TrimSpace(format))
|
||||
if normalized != "openai" {
|
||||
return ExtractSummaryConfig(body, normalized)
|
||||
}
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return SummaryConfig{}
|
||||
}
|
||||
config, _ := extractOpenAIExplicitSummaryConfig(body)
|
||||
return config
|
||||
}
|
||||
|
||||
// ApplySummaryConfig writes canonical summary intent in the target protocol.
|
||||
func ApplySummaryConfig(body []byte, format string, config SummaryConfig) []byte {
|
||||
return ApplySummaryConfigForModel(body, format, "", config)
|
||||
}
|
||||
|
||||
// ApplySummaryConfigForModel writes canonical summary intent in the target
|
||||
// protocol and uses target model capabilities when a valid target request must
|
||||
// activate thinking before it can request summaries.
|
||||
func ApplySummaryConfigForModel(body []byte, format, model string, config SummaryConfig) []byte {
|
||||
return applySummaryConfigForModel(body, format, model, nil, config)
|
||||
}
|
||||
|
||||
// applySummaryConfigForModel uses the resolved model definition when execution
|
||||
// selected a configured API-key model whose capability is not globally visible.
|
||||
func applySummaryConfigForModel(body []byte, format, model string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte {
|
||||
return applySummaryConfigForProvider(body, format, model, "", modelInfo, config)
|
||||
}
|
||||
|
||||
// applySummaryConfigForProvider uses the execution provider identity for Chat
|
||||
// dialects whose visibility controls are not part of the OpenAI wire format.
|
||||
func applySummaryConfigForProvider(body []byte, format, model, provider string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte {
|
||||
normalized := strings.ToLower(strings.TrimSpace(format))
|
||||
if config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return body
|
||||
}
|
||||
|
||||
enabled := config.Mode == SummaryEnabled
|
||||
switch normalized {
|
||||
case "openai":
|
||||
body = applyOpenAIChatSummaryConfig(body, provider, enabled)
|
||||
case "claude":
|
||||
// Anthropic documents display as invalid with thinking.type=disabled and
|
||||
// requires it alongside adaptive or enabled thinking. Model defaults differ:
|
||||
// Opus 5 and Sonnet 5 default to adaptive thinking; Fable/Mythos 5 are always
|
||||
// on. Opus 4.8/4.7/4.6, Sonnet 4.6, and the 4.5 models default to thinking
|
||||
// off. The newest models also default display to omitted. Keeping a missing
|
||||
// thinking block absent therefore preserves both kinds of model default;
|
||||
// absence does not mean every Claude model runs without thinking. Only an
|
||||
// enabled summary may activate a valid target thinking mode so that summarized
|
||||
// text can be returned. A disabled summary only adds omitted to an
|
||||
// already-active target mode.
|
||||
//
|
||||
// Anthropic docs:
|
||||
// https://platform.claude.com/docs/en/build-with-claude/thinking
|
||||
// https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#supported-models
|
||||
if enabled && !gjson.GetBytes(body, "thinking.type").Exists() {
|
||||
body = enableClaudeThinkingForSummary(body, model, modelInfo)
|
||||
}
|
||||
if !claudeThinkingAcceptsDisplay(body) {
|
||||
return body
|
||||
}
|
||||
value := "omitted"
|
||||
if enabled {
|
||||
value = "summarized"
|
||||
}
|
||||
body, _ = sjson.SetBytes(body, "thinking.display", value)
|
||||
case "gemini":
|
||||
body, _ = sjson.SetBytes(body, "generationConfig.thinkingConfig.includeThoughts", enabled)
|
||||
for _, path := range []string{
|
||||
"generationConfig.thinkingConfig.include_thoughts",
|
||||
"generation_config.thinking_config.include_thoughts",
|
||||
"generation_config.thinking_config.includeThoughts",
|
||||
} {
|
||||
body, _ = sjson.DeleteBytes(body, path)
|
||||
}
|
||||
case "antigravity":
|
||||
body, _ = sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", enabled)
|
||||
for _, path := range []string{
|
||||
"request.generationConfig.thinkingConfig.include_thoughts",
|
||||
"request.generationConfig.thinking_config.include_thoughts",
|
||||
"request.generationConfig.thinking_config.includeThoughts",
|
||||
} {
|
||||
body, _ = sjson.DeleteBytes(body, path)
|
||||
}
|
||||
case "interactions":
|
||||
// Google Interactions only accepts auto or none. OpenAI's concise and
|
||||
// detailed selectors therefore collapse to the supported enabled value.
|
||||
value := "none"
|
||||
if enabled {
|
||||
value = "auto"
|
||||
}
|
||||
body, _ = sjson.SetBytes(body, "generation_config.thinking_summaries", value)
|
||||
body, _ = sjson.DeleteBytes(body, "generation_config.thinkingSummaries")
|
||||
case "openai-response", "codex":
|
||||
if enabled {
|
||||
body, _ = sjson.SetBytes(body, "reasoning.summary", normalizedSummaryDetail(config.Detail))
|
||||
body, _ = sjson.DeleteBytes(body, "reasoning.generate_summary")
|
||||
break
|
||||
}
|
||||
// Omitting the field is the documented way to disable summaries; an
|
||||
// explicit null is not accepted by every Responses-compatible backend.
|
||||
body, _ = sjson.DeleteBytes(body, "reasoning.summary")
|
||||
body, _ = sjson.DeleteBytes(body, "reasoning.generate_summary")
|
||||
if reasoning := gjson.GetBytes(body, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
|
||||
body, _ = sjson.DeleteBytes(body, "reasoning")
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// summaryFormatSupported reports whether a protocol carries summary visibility
|
||||
// intent that this package can read or write.
|
||||
func summaryFormatSupported(format string) bool {
|
||||
switch format {
|
||||
case "openai", "openai-response", "codex", "claude", "gemini", "antigravity", "interactions":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// claudeThinkingAcceptsDisplay reports whether the body carries an active
|
||||
// thinking block that can hold a display field.
|
||||
func claudeThinkingAcceptsDisplay(body []byte) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) {
|
||||
case "adaptive":
|
||||
return true
|
||||
case "enabled":
|
||||
// This runs before ApplyThinking normalizes the request, so a missing
|
||||
// budget_tokens is an unfinished body rather than inactive thinking. CPA
|
||||
// also accepts -1 as its compatibility representation for auto thinking.
|
||||
budget := gjson.GetBytes(body, "thinking.budget_tokens")
|
||||
if budget.Type != gjson.Number {
|
||||
return true
|
||||
}
|
||||
value := budget.Int()
|
||||
return value == -1 || value > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// applyOpenAIChatSummaryConfig writes only documented Chat visibility controls.
|
||||
//
|
||||
// OpenAI Chat Completions exposes reasoning_effort but no reasoning summary or
|
||||
// visibility parameter. DeepSeek and Kimi Chat return reasoning_content while
|
||||
// thinking is active, but likewise document no independent hide/show switch.
|
||||
// Summary intent must therefore never invent or overwrite thinking effort for
|
||||
// those dialects. OpenRouter is the exception: reasoning.exclude is its
|
||||
// documented "reason but hide" control, and include_reasoning is its deprecated
|
||||
// inverse alias. Unknown OpenAI-compatible providers are handled conservatively
|
||||
// by updating those fields only when the payload already carries them.
|
||||
//
|
||||
// Docs:
|
||||
// https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
|
||||
// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
|
||||
// https://api-docs.deepseek.com/guides/thinking_mode
|
||||
// https://platform.kimi.ai/docs/api/chat
|
||||
func applyOpenAIChatSummaryConfig(body []byte, provider string, enabled bool) []byte {
|
||||
if isOpenRouterProvider(provider) || gjson.GetBytes(body, "reasoning.exclude").IsBool() {
|
||||
body, _ = sjson.SetBytes(body, "reasoning.exclude", !enabled)
|
||||
}
|
||||
if gjson.GetBytes(body, "include_reasoning").IsBool() {
|
||||
body, _ = sjson.SetBytes(body, "include_reasoning", enabled)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func isOpenRouterProvider(provider string) bool {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider == "openrouter" {
|
||||
return true
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(provider, func(r rune) bool {
|
||||
return r == '-' || r == '_' || r == '/' || r == '.' || r == ':'
|
||||
}) {
|
||||
if part == "openrouter" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) {
|
||||
// Google's documented Chat Completions extension is the authoritative
|
||||
// explicit visibility control when present, ahead of CPA compatibility
|
||||
// aliases and Chat's reasoning_effort fallback.
|
||||
for _, path := range []string{
|
||||
"extra_body.google.thinking_config.include_thoughts",
|
||||
"extra_body.google.thinking_config.includeThoughts",
|
||||
"extra_body.google.thinkingConfig.include_thoughts",
|
||||
"extra_body.google.thinkingConfig.includeThoughts",
|
||||
"extra_body.extra_body.google.thinking_config.include_thoughts",
|
||||
"extra_body.extra_body.google.thinking_config.includeThoughts",
|
||||
"google.thinking_config.include_thoughts",
|
||||
"google.thinking_config.includeThoughts",
|
||||
"thinking.includeThoughts",
|
||||
"thinking.include_thoughts",
|
||||
"reasoning.includeThoughts",
|
||||
"reasoning.include_thoughts",
|
||||
"generationConfig.thinkingConfig.includeThoughts",
|
||||
"generationConfig.thinkingConfig.include_thoughts",
|
||||
"generation_config.thinking_config.include_thoughts",
|
||||
"generation_config.thinking_config.includeThoughts",
|
||||
} {
|
||||
if config, ok := summaryBoolConfig(body, path); ok {
|
||||
return config, true
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"reasoning.summary",
|
||||
"reasoning.generate_summary",
|
||||
} {
|
||||
if config, ok := responsesSummaryConfig(body, path); ok {
|
||||
return config, true
|
||||
}
|
||||
}
|
||||
|
||||
// reasoning.exclude is OpenRouter's documented "reason but hide" bit, not an
|
||||
// OpenAI wire field; include_reasoning is its documented legacy alias
|
||||
// (include_reasoning: false is equivalent to reasoning: {exclude: true}).
|
||||
// Only accept actual JSON booleans.
|
||||
if exclude := gjson.GetBytes(body, "reasoning.exclude"); exclude.IsBool() {
|
||||
if exclude.Bool() {
|
||||
return SummaryConfig{Mode: SummaryDisabled}, true
|
||||
}
|
||||
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
|
||||
}
|
||||
if include := gjson.GetBytes(body, "include_reasoning"); include.IsBool() {
|
||||
if include.Bool() {
|
||||
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
|
||||
}
|
||||
return SummaryConfig{Mode: SummaryDisabled}, true
|
||||
}
|
||||
// OpenRouter's reasoning.enabled turns reasoning on "with no exclusions", so
|
||||
// it also decides visibility when no dedicated bit was sent.
|
||||
if enabled := gjson.GetBytes(body, "reasoning.enabled"); enabled.IsBool() {
|
||||
if enabled.Bool() {
|
||||
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
|
||||
}
|
||||
return SummaryConfig{Mode: SummaryDisabled}, true
|
||||
}
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
|
||||
func firstSummaryBoolConfig(body []byte, paths []string) (SummaryConfig, bool) {
|
||||
for _, path := range paths {
|
||||
if config, ok := summaryBoolConfig(body, path); ok {
|
||||
return config, true
|
||||
}
|
||||
}
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
|
||||
func summaryBoolConfig(body []byte, path string) (SummaryConfig, bool) {
|
||||
switch value := gjson.GetBytes(body, path); value.Type {
|
||||
case gjson.True:
|
||||
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
|
||||
case gjson.False:
|
||||
return SummaryConfig{Mode: SummaryDisabled}, true
|
||||
default:
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func responsesSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
|
||||
value := gjson.GetBytes(body, path)
|
||||
if value.Raw == "" {
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
if value.Type == gjson.Null {
|
||||
return SummaryConfig{Mode: SummaryDisabled}, true
|
||||
}
|
||||
if value.Type != gjson.String {
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
|
||||
raw := strings.ToLower(strings.TrimSpace(value.String()))
|
||||
switch raw {
|
||||
case "auto", "concise", "detailed":
|
||||
return SummaryConfig{Mode: SummaryEnabled, Detail: raw}, true
|
||||
case "none":
|
||||
// Compatibility with clients that expose a none enum; the OpenAI wire
|
||||
// representation disables summaries by omitting the field.
|
||||
return SummaryConfig{Mode: SummaryDisabled}, true
|
||||
default:
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func claudeSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
|
||||
value := gjson.GetBytes(body, path)
|
||||
if value.Type != gjson.String {
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(value.String())) {
|
||||
case "summarized":
|
||||
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
|
||||
case "omitted":
|
||||
return SummaryConfig{Mode: SummaryDisabled}, true
|
||||
default:
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func interactionsSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
|
||||
value := gjson.GetBytes(body, path)
|
||||
if value.Type != gjson.String {
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(value.String())) {
|
||||
case "auto":
|
||||
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
|
||||
case "none":
|
||||
return SummaryConfig{Mode: SummaryDisabled}, true
|
||||
default:
|
||||
return SummaryConfig{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// stripInferredClaudeSummaryActivation removes a globally inferred adaptive
|
||||
// mode when the selected API-key model supports only manual extended thinking.
|
||||
// The exact model-aware summary pass can then activate enabled thinking with a
|
||||
// valid budget, or leave thinking absent when max_tokens cannot accommodate it.
|
||||
func stripInferredClaudeSummaryActivation(body []byte, modelInfo *registry.ModelInfo) []byte {
|
||||
if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) > 0 || modelInfo.Thinking.Min <= 0 {
|
||||
return body
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()), "adaptive") {
|
||||
return body
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"thinking.type",
|
||||
"thinking.budget_tokens",
|
||||
"thinking.display",
|
||||
"output_config.effort",
|
||||
} {
|
||||
body, _ = sjson.DeleteBytes(body, path)
|
||||
}
|
||||
for _, path := range []string{"thinking", "output_config"} {
|
||||
if object := gjson.GetBytes(body, path); object.Exists() && object.IsObject() && len(object.Map()) == 0 {
|
||||
body, _ = sjson.DeleteBytes(body, path)
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func enableClaudeThinkingForSummary(body []byte, model string, resolvedModelInfo *registry.ModelInfo) []byte {
|
||||
modelInfo := resolvedModelInfo
|
||||
if modelInfo == nil {
|
||||
baseModel := ParseSuffix(model).ModelName
|
||||
if baseModel == "" {
|
||||
baseModel = ParseSuffix(gjson.GetBytes(body, "model").String()).ModelName
|
||||
}
|
||||
modelInfo = registry.LookupModelInfo(baseModel, "claude")
|
||||
}
|
||||
if modelInfo == nil || modelInfo.Thinking == nil {
|
||||
return body
|
||||
}
|
||||
|
||||
if len(modelInfo.Thinking.Levels) > 0 {
|
||||
body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
|
||||
body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens")
|
||||
return body
|
||||
}
|
||||
|
||||
budget := modelInfo.Thinking.Min
|
||||
if budget <= 0 {
|
||||
return body
|
||||
}
|
||||
if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() && maxTokens.Int() <= int64(budget) {
|
||||
return body
|
||||
}
|
||||
body, _ = sjson.SetBytes(body, "thinking.type", "enabled")
|
||||
body, _ = sjson.SetBytes(body, "thinking.budget_tokens", budget)
|
||||
return body
|
||||
}
|
||||
|
||||
func normalizedSummaryDetail(detail string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(detail)) {
|
||||
case "concise":
|
||||
return "concise"
|
||||
case "detailed":
|
||||
return "detailed"
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
288
backend/internal/thinking/summary_test.go
Normal file
288
backend/internal/thinking/summary_test.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
package thinking
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestExtractSummaryConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
body string
|
||||
wantMode SummaryMode
|
||||
wantDetail string
|
||||
}{
|
||||
{name: "chat effort enables", format: "openai", body: `{"reasoning_effort":"high"}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "chat none disables", format: "openai", body: `{"reasoning_effort":"none"}`, wantMode: SummaryDisabled},
|
||||
{name: "chat missing unspecified", format: "openai", body: `{}`, wantMode: SummaryUnspecified},
|
||||
{name: "chat null effort unspecified", format: "openai", body: `{"reasoning_effort":null}`, wantMode: SummaryUnspecified},
|
||||
{name: "chat non-string effort unspecified", format: "openai", body: `{"reasoning_effort":17}`, wantMode: SummaryUnspecified},
|
||||
{name: "chat google extension false overrides effort", format: "openai", body: `{"reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}}`, wantMode: SummaryDisabled},
|
||||
{name: "chat google extension true", format: "openai", body: `{"extra_body":{"google":{"thinking_config":{"include_thoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "chat exclude disables", format: "openai", body: `{"reasoning_effort":"high","reasoning":{"exclude":true}}`, wantMode: SummaryDisabled},
|
||||
{name: "chat exclude false enables", format: "openai", body: `{"reasoning":{"effort":"high","exclude":false}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "chat legacy include_reasoning false disables", format: "openai", body: `{"reasoning_effort":"high","include_reasoning":false}`, wantMode: SummaryDisabled},
|
||||
{name: "chat legacy include_reasoning true enables", format: "openai", body: `{"include_reasoning":true}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "chat reasoning enabled false disables", format: "openai", body: `{"reasoning":{"enabled":false}}`, wantMode: SummaryDisabled},
|
||||
{name: "chat reasoning enabled true enables", format: "openai", body: `{"reasoning":{"enabled":true}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "chat exclude wins over include_reasoning", format: "openai", body: `{"reasoning":{"exclude":true},"include_reasoning":true}`, wantMode: SummaryDisabled},
|
||||
{name: "chat non-boolean include_reasoning unspecified", format: "openai", body: `{"include_reasoning":"false"}`, wantMode: SummaryUnspecified},
|
||||
{name: "responses effort alone unspecified", format: "openai-response", body: `{"reasoning":{"effort":"high"}}`, wantMode: SummaryUnspecified},
|
||||
{name: "responses summary auto", format: "openai-response", body: `{"reasoning":{"effort":"high","summary":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "responses summary concise", format: "openai-response", body: `{"reasoning":{"summary":"concise"}}`, wantMode: SummaryEnabled, wantDetail: "concise"},
|
||||
{name: "responses summary null", format: "openai-response", body: `{"reasoning":{"summary":null}}`, wantMode: SummaryDisabled},
|
||||
{name: "responses boolean summary invalid", format: "openai-response", body: `{"reasoning":{"summary":true}}`, wantMode: SummaryUnspecified},
|
||||
{name: "responses deprecated generate summary", format: "openai-response", body: `{"reasoning":{"generate_summary":"detailed"}}`, wantMode: SummaryEnabled, wantDetail: "detailed"},
|
||||
{name: "claude summarized", format: "claude", body: `{"thinking":{"type":"adaptive","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "claude omitted", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":2048,"display":"omitted"}}`, wantMode: SummaryDisabled},
|
||||
{name: "claude display without type is invalid", format: "claude", body: `{"thinking":{"display":"summarized"}}`, wantMode: SummaryUnspecified},
|
||||
{name: "claude display with auto type is invalid", format: "claude", body: `{"thinking":{"type":"auto","display":"summarized"}}`, wantMode: SummaryUnspecified},
|
||||
// ApplySummaryConfig runs before ApplyThinking fills budget_tokens, so an
|
||||
// absent budget must not be read as inactive thinking.
|
||||
{name: "claude enabled display without budget is valid", format: "claude", body: `{"thinking":{"type":"enabled","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "claude enabled display with zero budget is invalid", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":0,"display":"summarized"}}`, wantMode: SummaryUnspecified},
|
||||
{name: "claude auto compatibility budget summarized", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":-1,"display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "claude auto compatibility budget omitted", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":-1,"display":"omitted"}}`, wantMode: SummaryDisabled},
|
||||
{name: "gemini include true", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "gemini include false", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled},
|
||||
{name: "antigravity include true", format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "interactions auto", format: "interactions", body: `{"generation_config":{"thinking_summaries":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "interactions none", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"}}`, wantMode: SummaryDisabled},
|
||||
{name: "interactions nested snake include false", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":false}}}`, wantMode: SummaryDisabled},
|
||||
{name: "interactions nested camel include true", format: "interactions", body: `{"generation_config":{"thinking_config":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "interactions camel config snake include true", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"include_thoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "interactions camel config camel include false", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled},
|
||||
{name: "interactions enum wins over compatibility reasoning", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"},"reasoning":{"summary":"auto"}}`, wantMode: SummaryDisabled},
|
||||
{name: "interactions compatibility reasoning auto", format: "interactions", body: `{"reasoning":{"summary":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
||||
{name: "interactions compatibility reasoning none", format: "interactions", body: `{"reasoning":{"summary":"none"}}`, wantMode: SummaryDisabled},
|
||||
{name: "interactions enum wins over include alias", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none","thinking_config":{"include_thoughts":true}}}`, wantMode: SummaryDisabled},
|
||||
{name: "interactions string include alias is invalid", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":"false"}}}`, wantMode: SummaryUnspecified},
|
||||
{name: "interactions detailed is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":"detailed"}}`, wantMode: SummaryUnspecified},
|
||||
{name: "interactions boolean is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":true}}`, wantMode: SummaryUnspecified},
|
||||
{name: "gemini string bool is invalid", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":"true"}}}`, wantMode: SummaryUnspecified},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := ExtractSummaryConfig([]byte(test.body), test.format)
|
||||
if got.Mode != test.wantMode || got.Detail != test.wantDetail {
|
||||
t.Fatalf("ExtractSummaryConfig() = %+v, want mode=%v detail=%q", got, test.wantMode, test.wantDetail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractExplicitSummaryConfigDoesNotUseChatEffort(t *testing.T) {
|
||||
body := []byte(`{"reasoning_effort":"high"}`)
|
||||
if got := ExtractExplicitSummaryConfig(body, "openai"); got.Mode != SummaryUnspecified {
|
||||
t.Fatalf("ExtractExplicitSummaryConfig() = %+v, want unspecified", got)
|
||||
}
|
||||
|
||||
body = []byte(`{"reasoning_effort":"high","reasoning":{"exclude":true}}`)
|
||||
if got := ExtractExplicitSummaryConfig(body, "openai"); got.Mode != SummaryDisabled {
|
||||
t.Fatalf("ExtractExplicitSummaryConfig() = %+v, want disabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySummaryConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
body string
|
||||
config SummaryConfig
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{name: "chat enabled invents no effort", format: "openai", config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: ""},
|
||||
{name: "chat enabled preserves active effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "high"},
|
||||
{name: "chat enabled preserves disabled effort", format: "openai", body: `{"reasoning_effort":"none"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "none"},
|
||||
// Chat cannot express "reason but hide", so disabling must not fall back to
|
||||
// reasoning_effort:"none", which would disable reasoning altogether.
|
||||
{name: "chat disabled preserves requested effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning_effort", want: "high"},
|
||||
{name: "chat disabled sets openrouter exclude when present", format: "openai", body: `{"reasoning":{"effort":"high","exclude":false}}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning.exclude", want: "true"},
|
||||
{name: "chat enabled clears openrouter exclude when present", format: "openai", body: `{"reasoning":{"effort":"high","exclude":true}}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning.exclude", want: "false"},
|
||||
{name: "chat disabled updates legacy include_reasoning when present", format: "openai", body: `{"reasoning_effort":"high","include_reasoning":true}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "include_reasoning", want: "false"},
|
||||
{name: "chat disabled invents no openrouter field", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning", want: ""},
|
||||
{name: "claude enabled", format: "claude", body: `{"thinking":{"type":"adaptive"}}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "thinking.display", want: "summarized"},
|
||||
{name: "claude disabled", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":2048}}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "thinking.display", want: "omitted"},
|
||||
{name: "gemini enabled", format: "gemini", config: SummaryConfig{Mode: SummaryEnabled}, path: "generationConfig.thinkingConfig.includeThoughts", want: "true"},
|
||||
{name: "gemini disabled", format: "gemini", config: SummaryConfig{Mode: SummaryDisabled}, path: "generationConfig.thinkingConfig.includeThoughts", want: "false"},
|
||||
{name: "antigravity enabled", format: "antigravity", config: SummaryConfig{Mode: SummaryEnabled}, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true"},
|
||||
{name: "interactions detail collapses to auto", format: "interactions", config: SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"}, path: "generation_config.thinking_summaries", want: "auto"},
|
||||
{name: "interactions disabled", format: "interactions", config: SummaryConfig{Mode: SummaryDisabled}, path: "generation_config.thinking_summaries", want: "none"},
|
||||
{name: "responses concise", format: "openai-response", config: SummaryConfig{Mode: SummaryEnabled, Detail: "concise"}, path: "reasoning.summary", want: "concise"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := test.body
|
||||
if body == "" {
|
||||
body = `{}`
|
||||
}
|
||||
out := ApplySummaryConfig([]byte(body), test.format, test.config)
|
||||
if got := gjson.GetBytes(out, test.path).String(); got != test.want {
|
||||
t.Fatalf("%s = %q, want %q; body=%s", test.path, got, test.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySummaryConfig_OpenAIChatProviderDialects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
body string
|
||||
mode SummaryMode
|
||||
wantExclude string
|
||||
wantExisting bool
|
||||
wantEffort string
|
||||
}{
|
||||
{name: "OpenAI does not invent visibility", provider: "openai", body: `{}`, mode: SummaryEnabled},
|
||||
{name: "OpenRouter enables visibility", provider: "openrouter", body: `{}`, mode: SummaryEnabled, wantExclude: "false", wantExisting: true},
|
||||
{name: "OpenRouter disables visibility", provider: "prod-openrouter", body: `{}`, mode: SummaryDisabled, wantExclude: "true", wantExisting: true},
|
||||
{name: "DeepSeek preserves documented effort", provider: "deepseek", body: `{"reasoning_effort":"high"}`, mode: SummaryDisabled, wantEffort: "high"},
|
||||
{name: "Kimi preserves documented K3 effort", provider: "kimi", body: `{"reasoning_effort":"max"}`, mode: SummaryEnabled, wantEffort: "max"},
|
||||
{name: "Moonshot does not invent visibility", provider: "moonshot", body: `{"thinking":{"type":"enabled"}}`, mode: SummaryEnabled},
|
||||
{name: "generic provider updates existing OpenRouter field", provider: "openai-compatibility", body: `{"reasoning":{"exclude":false}}`, mode: SummaryDisabled, wantExclude: "true", wantExisting: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
out := applySummaryConfigForProvider([]byte(test.body), "openai", "model", test.provider, nil, SummaryConfig{Mode: test.mode})
|
||||
exclude := gjson.GetBytes(out, "reasoning.exclude")
|
||||
if exclude.Exists() != test.wantExisting {
|
||||
t.Fatalf("reasoning.exclude exists = %v, want %v; body=%s", exclude.Exists(), test.wantExisting, out)
|
||||
}
|
||||
if test.wantExisting && exclude.String() != test.wantExclude {
|
||||
t.Fatalf("reasoning.exclude = %q, want %q; body=%s", exclude.String(), test.wantExclude, out)
|
||||
}
|
||||
effort := gjson.GetBytes(out, "reasoning_effort")
|
||||
if test.wantEffort == "" {
|
||||
if effort.Exists() {
|
||||
t.Fatalf("summary visibility invented reasoning_effort: %s", out)
|
||||
}
|
||||
} else if effort.String() != test.wantEffort {
|
||||
t.Fatalf("reasoning_effort = %q, want %q; body=%s", effort.String(), test.wantEffort, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySummaryConfigNormalizesTargetAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
format string
|
||||
body string
|
||||
canonical string
|
||||
alias string
|
||||
}{
|
||||
{format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}`, canonical: "generationConfig.thinkingConfig.includeThoughts", alias: "generationConfig.thinkingConfig.include_thoughts"},
|
||||
{format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}}`, canonical: "request.generationConfig.thinkingConfig.includeThoughts", alias: "request.generationConfig.thinkingConfig.include_thoughts"},
|
||||
{format: "interactions", body: `{"generation_config":{"thinkingSummaries":"auto"}}`, canonical: "generation_config.thinking_summaries", alias: "generation_config.thinkingSummaries"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
out := ApplySummaryConfig([]byte(test.body), test.format, SummaryConfig{Mode: SummaryEnabled})
|
||||
if !gjson.GetBytes(out, test.canonical).Exists() {
|
||||
t.Fatalf("%s missing canonical field: %s", test.format, out)
|
||||
}
|
||||
if gjson.GetBytes(out, test.alias).Exists() {
|
||||
t.Fatalf("%s retained alias %s: %s", test.format, test.alias, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic requires thinking.type, and rejects display on a disabled block, so
|
||||
// display must never be written unless thinking is already active.
|
||||
func TestApplySummaryConfig_ClaudeDisplayRequiresActiveThinking(t *testing.T) {
|
||||
bodies := []string{
|
||||
`{}`,
|
||||
`{"messages":[{"role":"user","content":"hi"}]}`,
|
||||
`{"thinking":{"type":"disabled"}}`,
|
||||
}
|
||||
for _, mode := range []SummaryMode{SummaryEnabled, SummaryDisabled} {
|
||||
for _, body := range bodies {
|
||||
out := ApplySummaryConfig([]byte(body), "claude", SummaryConfig{Mode: mode})
|
||||
if gjson.GetBytes(out, "thinking.display").Exists() {
|
||||
t.Fatalf("mode %v wrote display without active thinking: %s", mode, out)
|
||||
}
|
||||
if !bytes.Equal(out, []byte(body)) {
|
||||
t.Fatalf("mode %v changed body: got %s, want %s", mode, out, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySummaryConfigForModel_ClaudeEnabledSummaryUsesValidThinkingMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
body string
|
||||
wantType string
|
||||
wantBudget int64
|
||||
}{
|
||||
{name: "adaptive model", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, wantType: "adaptive"},
|
||||
{name: "manual model", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, wantType: "enabled", wantBudget: 1024},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: SummaryEnabled})
|
||||
if got := gjson.GetBytes(out, "thinking.type").String(); got != test.wantType {
|
||||
t.Fatalf("thinking.type = %q, want %q; body=%s", got, test.wantType, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
|
||||
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out)
|
||||
}
|
||||
if test.wantBudget > 0 && gjson.GetBytes(out, "thinking.budget_tokens").Int() != test.wantBudget {
|
||||
t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", gjson.GetBytes(out, "thinking.budget_tokens").Int(), test.wantBudget, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Disabling summaries must not make CPA add a Claude thinking block. Absence
|
||||
// preserves the per-model default: newer models may still think by default,
|
||||
// while older models remain off.
|
||||
func TestApplySummaryConfigForModel_ClaudeDisabledSummaryDoesNotEnableThinking(t *testing.T) {
|
||||
for _, model := range []string{"claude-opus-5", "claude-haiku-4-5-20251001"} {
|
||||
body := []byte(`{"model":"` + model + `","max_tokens":32000}`)
|
||||
out := ApplySummaryConfigForModel(body, "claude", model, SummaryConfig{Mode: SummaryDisabled})
|
||||
if gjson.GetBytes(out, "thinking").Exists() {
|
||||
t.Fatalf("model %s gained thinking for a disabled summary: %s", model, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySummaryConfig_ResponsesNormalizesDeprecatedGenerateSummary(t *testing.T) {
|
||||
out := ApplySummaryConfig([]byte(`{"reasoning":{"generate_summary":"detailed"}}`), "openai-response", SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"})
|
||||
if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "detailed" {
|
||||
t.Fatalf("reasoning.summary = %q, want detailed; body=%s", got, out)
|
||||
}
|
||||
if gjson.GetBytes(out, "reasoning.generate_summary").Exists() {
|
||||
t.Fatalf("deprecated reasoning.generate_summary remained: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySummaryConfig_ResponsesDisabledOmitsSummary(t *testing.T) {
|
||||
out := ApplySummaryConfig([]byte(`{"reasoning":{"effort":"high","summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled})
|
||||
if result := gjson.GetBytes(out, "reasoning.summary"); result.Exists() {
|
||||
t.Fatalf("reasoning.summary = %s, want absent; body=%s", result.Raw, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" {
|
||||
t.Fatalf("reasoning.effort = %q, want high; body=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySummaryConfig_ResponsesDisabledDropsEmptyReasoning(t *testing.T) {
|
||||
out := ApplySummaryConfig([]byte(`{"model":"gpt-5.4","reasoning":{"summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled})
|
||||
if gjson.GetBytes(out, "reasoning").Exists() {
|
||||
t.Fatalf("empty reasoning object left behind: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySummaryConfig_UnspecifiedLeavesBodyUnchanged(t *testing.T) {
|
||||
body := []byte(`{"thinking":{"type":"adaptive"}}`)
|
||||
if got := ApplySummaryConfig(body, "claude", SummaryConfig{}); !bytes.Equal(got, body) {
|
||||
t.Fatalf("unspecified summary changed body: got %s, want %s", got, body)
|
||||
}
|
||||
}
|
||||
41
backend/internal/thinking/text.go
Normal file
41
backend/internal/thinking/text.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package thinking
|
||||
|
||||
import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// GetThinkingText extracts the thinking text from a content part.
|
||||
// Handles various formats:
|
||||
// - Simple string: { "thinking": "text" } or { "text": "text" }
|
||||
// - Wrapped object: { "thinking": { "text": "text", "cache_control": {...} } }
|
||||
// - Gemini-style: { "thought": true, "text": "text" }
|
||||
// Returns the extracted text string.
|
||||
func GetThinkingText(part gjson.Result) string {
|
||||
// Try direct text field first (Gemini-style)
|
||||
if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
|
||||
return text.String()
|
||||
}
|
||||
|
||||
// Try thinking field
|
||||
thinkingField := part.Get("thinking")
|
||||
if !thinkingField.Exists() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// thinking is a string
|
||||
if thinkingField.Type == gjson.String {
|
||||
return thinkingField.String()
|
||||
}
|
||||
|
||||
// thinking is an object with inner text/thinking
|
||||
if thinkingField.IsObject() {
|
||||
if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
|
||||
return inner.String()
|
||||
}
|
||||
if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
|
||||
return inner.String()
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
119
backend/internal/thinking/types.go
Normal file
119
backend/internal/thinking/types.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Package thinking provides unified thinking configuration processing.
|
||||
//
|
||||
// This package offers a unified interface for parsing, validating, and applying
|
||||
// thinking configurations across various AI providers (Claude, Gemini, OpenAI, Codex, Antigravity, Kimi, xAI).
|
||||
package thinking
|
||||
|
||||
import "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
|
||||
// ThinkingMode represents the type of thinking configuration mode.
|
||||
type ThinkingMode int
|
||||
|
||||
const (
|
||||
// ModeBudget indicates using a numeric budget (corresponds to suffix "(1000)" etc.)
|
||||
ModeBudget ThinkingMode = iota
|
||||
// ModeLevel indicates using a discrete level (corresponds to suffix "(high)" etc.)
|
||||
ModeLevel
|
||||
// ModeNone indicates thinking is disabled (corresponds to suffix "(none)" or budget=0)
|
||||
ModeNone
|
||||
// ModeAuto indicates automatic/dynamic thinking (corresponds to suffix "(auto)" or budget=-1)
|
||||
ModeAuto
|
||||
)
|
||||
|
||||
// String returns the string representation of ThinkingMode.
|
||||
func (m ThinkingMode) String() string {
|
||||
switch m {
|
||||
case ModeBudget:
|
||||
return "budget"
|
||||
case ModeLevel:
|
||||
return "level"
|
||||
case ModeNone:
|
||||
return "none"
|
||||
case ModeAuto:
|
||||
return "auto"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ThinkingLevel represents a discrete thinking level.
|
||||
type ThinkingLevel string
|
||||
|
||||
const (
|
||||
// LevelNone disables thinking
|
||||
LevelNone ThinkingLevel = "none"
|
||||
// LevelAuto enables automatic/dynamic thinking
|
||||
LevelAuto ThinkingLevel = "auto"
|
||||
// LevelMinimal sets minimal thinking effort
|
||||
LevelMinimal ThinkingLevel = "minimal"
|
||||
// LevelLow sets low thinking effort
|
||||
LevelLow ThinkingLevel = "low"
|
||||
// LevelMedium sets medium thinking effort
|
||||
LevelMedium ThinkingLevel = "medium"
|
||||
// LevelHigh sets high thinking effort
|
||||
LevelHigh ThinkingLevel = "high"
|
||||
// LevelXHigh sets extra-high thinking effort
|
||||
LevelXHigh ThinkingLevel = "xhigh"
|
||||
// LevelMax sets maximum thinking effort.
|
||||
// This is currently used by Claude 4.6 adaptive thinking (opus supports "max").
|
||||
LevelMax ThinkingLevel = "max"
|
||||
)
|
||||
|
||||
// ThinkingConfig represents a unified thinking configuration.
|
||||
//
|
||||
// This struct is used to pass thinking configuration information between components.
|
||||
// Depending on Mode, either Budget or Level field is effective:
|
||||
// - ModeNone: Budget=0, Level is ignored
|
||||
// - ModeAuto: Budget=-1, Level is ignored
|
||||
// - ModeBudget: Budget is a positive integer, Level is ignored
|
||||
// - ModeLevel: Budget is ignored, Level is a valid level
|
||||
type ThinkingConfig struct {
|
||||
// Mode specifies the configuration mode
|
||||
Mode ThinkingMode
|
||||
// Budget is the thinking budget (token count), only effective when Mode is ModeBudget.
|
||||
// Special values: 0 means disabled, -1 means automatic
|
||||
Budget int
|
||||
// Level is the thinking level, only effective when Mode is ModeLevel
|
||||
Level ThinkingLevel
|
||||
}
|
||||
|
||||
// SuffixResult represents the result of parsing a model name for thinking suffix.
|
||||
//
|
||||
// A thinking suffix is specified in the format model-name(value), where value
|
||||
// can be a numeric budget (e.g., "16384") or a level name (e.g., "high").
|
||||
type SuffixResult struct {
|
||||
// ModelName is the model name with the suffix removed.
|
||||
// If no suffix was found, this equals the original input.
|
||||
ModelName string
|
||||
|
||||
// HasSuffix indicates whether a valid suffix was found.
|
||||
HasSuffix bool
|
||||
|
||||
// RawSuffix is the content inside the parentheses, without the parentheses.
|
||||
// Empty string if HasSuffix is false.
|
||||
RawSuffix string
|
||||
}
|
||||
|
||||
// ProviderApplier defines the interface for provider-specific thinking configuration application.
|
||||
//
|
||||
// Types implementing this interface are responsible for converting a unified ThinkingConfig
|
||||
// into provider-specific format and applying it to the request body.
|
||||
//
|
||||
// Implementation requirements:
|
||||
// - Apply method must be idempotent
|
||||
// - Must not modify the input config or modelInfo
|
||||
// - Returns a modified copy of the request body
|
||||
// - Returns appropriate ThinkingError for unsupported configurations
|
||||
type ProviderApplier interface {
|
||||
// Apply applies the thinking configuration to the request body.
|
||||
//
|
||||
// Parameters:
|
||||
// - body: Original request body JSON
|
||||
// - config: Unified thinking configuration
|
||||
// - modelInfo: Model registry information containing ThinkingSupport properties
|
||||
//
|
||||
// Returns:
|
||||
// - Modified request body JSON
|
||||
// - ThinkingError if the configuration is invalid or unsupported
|
||||
Apply(body []byte, config ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error)
|
||||
}
|
||||
417
backend/internal/thinking/validate.go
Normal file
417
backend/internal/thinking/validate.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
// Package thinking provides unified thinking configuration processing logic.
|
||||
package thinking
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ValidateConfig validates a thinking configuration against model capabilities.
|
||||
//
|
||||
// This function performs comprehensive validation:
|
||||
// - Checks if the model supports thinking
|
||||
// - Auto-converts between Budget and Level formats based on model capability
|
||||
// - Validates that requested level is in the model's supported levels list
|
||||
// - Clamps budget values to model's allowed range
|
||||
// - When converting Budget -> Level for level-only models, clamps the derived standard level to the nearest supported level
|
||||
// (special values none/auto are preserved)
|
||||
// - When config comes from a model suffix, strict budget validation is disabled (we clamp instead of error)
|
||||
//
|
||||
// Parameters:
|
||||
// - config: The thinking configuration to validate
|
||||
// - support: Model's ThinkingSupport properties (nil means no thinking support)
|
||||
// - fromFormat: Source provider format (used to determine strict validation rules)
|
||||
// - toFormat: Target provider format
|
||||
// - fromSuffix: Whether config was sourced from model suffix
|
||||
//
|
||||
// Returns:
|
||||
// - Normalized ThinkingConfig with clamped values
|
||||
// - ThinkingError if validation fails (ErrThinkingNotSupported, ErrLevelNotSupported, etc.)
|
||||
//
|
||||
// Auto-conversion behavior:
|
||||
// - Budget-only model + Level config → Level converted to Budget
|
||||
// - Level-only model + Budget config → Budget converted to Level
|
||||
// - Hybrid model → preserve original format
|
||||
func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFormat, toFormat string, fromSuffix bool) (*ThinkingConfig, error) {
|
||||
fromFormat, toFormat = strings.ToLower(strings.TrimSpace(fromFormat)), strings.ToLower(strings.TrimSpace(toFormat))
|
||||
model := "unknown"
|
||||
support := (*registry.ThinkingSupport)(nil)
|
||||
if modelInfo != nil {
|
||||
if modelInfo.ID != "" {
|
||||
model = modelInfo.ID
|
||||
}
|
||||
support = modelInfo.Thinking
|
||||
}
|
||||
|
||||
if support == nil {
|
||||
if config.Mode != ModeNone {
|
||||
return nil, NewThinkingErrorWithModel(ErrThinkingNotSupported, "thinking not supported for this model", model)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// allowClampUnsupported determines whether to clamp unsupported levels instead of returning an error.
|
||||
// This applies when crossing provider families (e.g., openai→gemini, claude→gemini) and the target
|
||||
// model supports discrete levels. Same-family conversions require strict validation.
|
||||
//
|
||||
// modelFamilyMismatch covers providers that reuse another protocol on the wire
|
||||
// (e.g. Kimi serving Claude-compatible /v1/messages). In that path fromFormat and
|
||||
// toFormat both look like "claude", but the model itself is not Claude-family, so
|
||||
// unsupported levels such as "max" should clamp to the nearest supported level
|
||||
// (typically "high") instead of failing validation.
|
||||
toCapability := detectModelCapability(modelInfo)
|
||||
toHasLevelSupport := toCapability == CapabilityLevelOnly || toCapability == CapabilityHybrid
|
||||
modelFamilyMismatch := false
|
||||
if modelInfo != nil {
|
||||
modelType := strings.ToLower(strings.TrimSpace(modelInfo.Type))
|
||||
if modelType != "" {
|
||||
if (fromFormat != "" && !isSameProviderFamily(fromFormat, modelType)) ||
|
||||
(toFormat != "" && !isSameProviderFamily(toFormat, modelType)) {
|
||||
modelFamilyMismatch = true
|
||||
}
|
||||
}
|
||||
}
|
||||
allowClampUnsupported := toHasLevelSupport && (!isSameProviderFamily(fromFormat, toFormat) || modelFamilyMismatch)
|
||||
|
||||
// strictBudget determines whether to enforce strict budget range validation.
|
||||
// This applies when: (1) config comes from request body (not suffix), (2) source format is known,
|
||||
// and (3) source and target are in the same provider family. Cross-family or suffix-based configs
|
||||
// are clamped instead of rejected to improve interoperability.
|
||||
strictBudget := !fromSuffix && fromFormat != "" && isSameProviderFamily(fromFormat, toFormat) && !modelFamilyMismatch
|
||||
budgetDerivedFromLevel := false
|
||||
|
||||
capability := detectModelCapability(modelInfo)
|
||||
switch capability {
|
||||
case CapabilityBudgetOnly:
|
||||
if config.Mode == ModeLevel {
|
||||
if config.Level == LevelAuto {
|
||||
break
|
||||
}
|
||||
budget, ok := ConvertLevelToBudget(string(config.Level))
|
||||
if !ok {
|
||||
return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("unknown level: %s", config.Level))
|
||||
}
|
||||
config.Mode = ModeBudget
|
||||
config.Budget = budget
|
||||
config.Level = ""
|
||||
budgetDerivedFromLevel = true
|
||||
}
|
||||
case CapabilityLevelOnly:
|
||||
if config.Mode == ModeBudget {
|
||||
level, ok := ConvertBudgetToLevel(config.Budget)
|
||||
if !ok {
|
||||
return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("budget %d cannot be converted to a valid level", config.Budget))
|
||||
}
|
||||
// When converting Budget -> Level for level-only models, clamp the derived standard level
|
||||
// to the nearest supported level. Special values (none/auto) are preserved.
|
||||
config.Mode = ModeLevel
|
||||
config.Level = clampLevel(ThinkingLevel(level), modelInfo, toFormat)
|
||||
config.Budget = 0
|
||||
}
|
||||
case CapabilityHybrid:
|
||||
}
|
||||
|
||||
if config.Mode == ModeLevel && config.Level == LevelNone {
|
||||
config.Mode = ModeNone
|
||||
config.Budget = 0
|
||||
config.Level = ""
|
||||
}
|
||||
if config.Mode == ModeLevel && config.Level == LevelAuto {
|
||||
config.Mode = ModeAuto
|
||||
config.Budget = -1
|
||||
config.Level = ""
|
||||
}
|
||||
if config.Mode == ModeBudget && config.Budget == 0 {
|
||||
config.Mode = ModeNone
|
||||
config.Level = ""
|
||||
}
|
||||
|
||||
if len(support.Levels) > 0 && config.Mode == ModeLevel {
|
||||
if !isLevelSupported(string(config.Level), support.Levels) {
|
||||
if allowClampUnsupported {
|
||||
config.Level = clampLevel(config.Level, modelInfo, toFormat)
|
||||
}
|
||||
if !isLevelSupported(string(config.Level), support.Levels) {
|
||||
// User explicitly specified an unsupported level - return error
|
||||
// (budget-derived levels may be clamped based on source format)
|
||||
validLevels := normalizeLevels(support.Levels)
|
||||
message := fmt.Sprintf("level %q not supported, valid levels: %s", strings.ToLower(string(config.Level)), strings.Join(validLevels, ", "))
|
||||
return nil, NewThinkingError(ErrLevelNotSupported, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strictBudget && config.Mode == ModeBudget && !budgetDerivedFromLevel {
|
||||
min, max := support.Min, support.Max
|
||||
if min != 0 || max != 0 {
|
||||
if config.Budget < min || config.Budget > max || (config.Budget == 0 && !support.ZeroAllowed) {
|
||||
message := fmt.Sprintf("budget %d out of range [%d,%d]", config.Budget, min, max)
|
||||
return nil, NewThinkingError(ErrBudgetOutOfRange, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert ModeAuto to mid-range if dynamic not allowed
|
||||
if config.Mode == ModeAuto && !support.DynamicAllowed {
|
||||
config = convertAutoToMidRange(config, support, toFormat, model)
|
||||
// The canonical mid-range level may not be present in a model's discrete
|
||||
// level subset (for example, Levels=[low, high]). Clamp the generated
|
||||
// fallback just like a budget-derived level so providers never receive an
|
||||
// unsupported value.
|
||||
if config.Mode == ModeLevel && len(support.Levels) > 0 && !isLevelSupported(string(config.Level), support.Levels) {
|
||||
config.Level = clampLevel(config.Level, modelInfo, toFormat)
|
||||
}
|
||||
}
|
||||
|
||||
if config.Mode == ModeNone && toFormat == "claude" {
|
||||
// Claude supports explicit disable via thinking.type="disabled".
|
||||
// Keep Budget=0 so applier can omit budget_tokens.
|
||||
config.Budget = 0
|
||||
config.Level = ""
|
||||
} else {
|
||||
switch config.Mode {
|
||||
case ModeBudget, ModeAuto, ModeNone:
|
||||
config.Budget = clampBudget(config.Budget, modelInfo, toFormat)
|
||||
}
|
||||
|
||||
// ModeNone for a model that cannot be disabled falls back to the lowest
|
||||
// supported level. Budget-capable models reach this path with Budget > 0;
|
||||
// level-only models need the capability flags checked explicitly because
|
||||
// their Min/Max range is zero.
|
||||
cannotDisableLevelModel := !support.ZeroAllowed && !isLevelSupported(string(LevelNone), support.Levels)
|
||||
if config.Mode == ModeNone && len(support.Levels) > 0 && (config.Budget > 0 || cannotDisableLevelModel) {
|
||||
config.Level = ThinkingLevel(support.Levels[0])
|
||||
}
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// convertAutoToMidRange converts ModeAuto to a mid-range value when dynamic is not allowed.
|
||||
//
|
||||
// This function handles the case where a model does not support dynamic/auto thinking.
|
||||
// The auto mode is silently converted to a fixed value based on model capability:
|
||||
// - Level-only models: convert to ModeLevel with LevelMedium
|
||||
// - Budget models: convert to ModeBudget with mid = (Min + Max) / 2
|
||||
//
|
||||
// Logging:
|
||||
// - Debug level when conversion occurs
|
||||
// - Fields: original_mode, clamped_to, reason
|
||||
func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupport, provider, model string) ThinkingConfig {
|
||||
// For level-only models (has Levels but no Min/Max range), use ModeLevel with medium
|
||||
if len(support.Levels) > 0 && support.Min == 0 && support.Max == 0 {
|
||||
config.Mode = ModeLevel
|
||||
config.Level = LevelMedium
|
||||
config.Budget = 0
|
||||
log.WithFields(log.Fields{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"original_mode": "auto",
|
||||
"clamped_to": string(LevelMedium),
|
||||
}).Debug("thinking: mode converted, dynamic not allowed, using medium level |")
|
||||
return config
|
||||
}
|
||||
|
||||
// For budget models, use mid-range budget
|
||||
mid := (support.Min + support.Max) / 2
|
||||
if mid <= 0 && support.ZeroAllowed {
|
||||
config.Mode = ModeNone
|
||||
config.Budget = 0
|
||||
} else if mid <= 0 {
|
||||
config.Mode = ModeBudget
|
||||
config.Budget = support.Min
|
||||
} else {
|
||||
config.Mode = ModeBudget
|
||||
config.Budget = mid
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"original_mode": "auto",
|
||||
"clamped_to": config.Budget,
|
||||
}).Debug("thinking: mode converted, dynamic not allowed |")
|
||||
return config
|
||||
}
|
||||
|
||||
// standardLevelOrder defines the canonical ordering of thinking levels from lowest to highest.
|
||||
var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh, LevelMax}
|
||||
|
||||
// clampLevel clamps the given level to the nearest supported level.
|
||||
// On tie, prefers the lower level.
|
||||
func clampLevel(level ThinkingLevel, modelInfo *registry.ModelInfo, provider string) ThinkingLevel {
|
||||
model := "unknown"
|
||||
var supported []string
|
||||
if modelInfo != nil {
|
||||
if modelInfo.ID != "" {
|
||||
model = modelInfo.ID
|
||||
}
|
||||
if modelInfo.Thinking != nil {
|
||||
supported = modelInfo.Thinking.Levels
|
||||
}
|
||||
}
|
||||
|
||||
if len(supported) == 0 || isLevelSupported(string(level), supported) {
|
||||
return level
|
||||
}
|
||||
|
||||
pos := levelIndex(string(level))
|
||||
if pos == -1 {
|
||||
return level
|
||||
}
|
||||
bestIdx, bestDist := -1, len(standardLevelOrder)+1
|
||||
|
||||
for _, s := range supported {
|
||||
if idx := levelIndex(strings.TrimSpace(s)); idx != -1 {
|
||||
if dist := abs(pos - idx); dist < bestDist || (dist == bestDist && idx < bestIdx) {
|
||||
bestIdx, bestDist = idx, dist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestIdx >= 0 {
|
||||
clamped := standardLevelOrder[bestIdx]
|
||||
log.WithFields(log.Fields{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"original_value": string(level),
|
||||
"clamped_to": string(clamped),
|
||||
}).Debug("thinking: level clamped |")
|
||||
return clamped
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// clampBudget clamps a budget value to the model's supported range.
|
||||
func clampBudget(value int, modelInfo *registry.ModelInfo, provider string) int {
|
||||
model := "unknown"
|
||||
support := (*registry.ThinkingSupport)(nil)
|
||||
if modelInfo != nil {
|
||||
if modelInfo.ID != "" {
|
||||
model = modelInfo.ID
|
||||
}
|
||||
support = modelInfo.Thinking
|
||||
}
|
||||
if support == nil {
|
||||
return value
|
||||
}
|
||||
|
||||
// Auto value (-1) passes through without clamping.
|
||||
if value == -1 {
|
||||
return value
|
||||
}
|
||||
|
||||
min, max := support.Min, support.Max
|
||||
if value == 0 && !support.ZeroAllowed {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"original_value": value,
|
||||
"clamped_to": min,
|
||||
"min": min,
|
||||
"max": max,
|
||||
}).Warn("thinking: budget zero not allowed |")
|
||||
return min
|
||||
}
|
||||
|
||||
// Some models are level-only and do not define numeric budget ranges.
|
||||
if min == 0 && max == 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
if value < min {
|
||||
if value == 0 && support.ZeroAllowed {
|
||||
return 0
|
||||
}
|
||||
logClamp(provider, model, value, min, min, max)
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
logClamp(provider, model, value, max, min, max)
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isLevelSupported(level string, supported []string) bool {
|
||||
for _, s := range supported {
|
||||
if strings.EqualFold(level, strings.TrimSpace(s)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func levelIndex(level string) int {
|
||||
for i, l := range standardLevelOrder {
|
||||
if strings.EqualFold(level, string(l)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func normalizeLevels(levels []string) []string {
|
||||
out := make([]string, len(levels))
|
||||
for i, l := range levels {
|
||||
out[i] = strings.ToLower(strings.TrimSpace(l))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isBudgetCapableProvider returns true if the provider supports budget-based thinking.
|
||||
// These providers may also support level-based thinking (hybrid models).
|
||||
func isBudgetCapableProvider(provider string) bool {
|
||||
switch provider {
|
||||
case "gemini", "antigravity", "claude":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isGeminiFamily(provider string) bool {
|
||||
switch provider {
|
||||
case "gemini", "antigravity":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isOpenAIFamily(provider string) bool {
|
||||
switch provider {
|
||||
case "openai", "openai-response", "codex":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isSameProviderFamily(from, to string) bool {
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
return (isGeminiFamily(from) && isGeminiFamily(to)) ||
|
||||
(isOpenAIFamily(from) && isOpenAIFamily(to))
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func logClamp(provider, model string, original, clampedTo, min, max int) {
|
||||
log.WithFields(log.Fields{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"original_value": original,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"clamped_to": clampedTo,
|
||||
}).Debug("thinking: budget clamped |")
|
||||
}
|
||||
Loading…
Reference in a new issue