Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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{}
}

View 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 := &registry.ModelInfo{
ID: "claude-upstream",
Type: "claude",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "compat-upstream",
Type: "openai-compatibility",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "codex-upstream",
Type: "codex",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "openai-upstream",
Type: "openai",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "private-claude",
Type: "claude",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "private-manual-claude",
Type: "claude",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "private-claude",
Type: "claude",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "private-openai",
Type: "openai",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "openrouter-model",
Type: "openai-compatibility",
Thinking: &registry.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 := &registry.ModelInfo{
ID: "claude-upstream",
Type: "claude",
Thinking: &registry.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)
}
}

View 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
}
}

View 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
}

View 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)
}
}

View 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
}

View 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
}
}

View 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
}

View 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
}

View 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
}
}

View 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
}

View 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
}

View 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())
}

View 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
}

View 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
}
}

View 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"
}
}

View 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)
}
}

View 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 ""
}

View 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)
}

View 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 |")
}