Merge pull request #1081 from router-for-me/thinking

Refine thinking validation and cross‑provider payload conversion
This commit is contained in:
Luis Pater
2026-01-18 13:34:28 +08:00
committed by GitHub
26 changed files with 3065 additions and 1181 deletions

View File

@@ -30,7 +30,7 @@ var (
type LogFormatter struct{} type LogFormatter struct{}
// logFieldOrder defines the display order for common log fields. // logFieldOrder defines the display order for common log fields.
var logFieldOrder = []string{"provider", "model", "mode", "budget", "level", "original_value", "min", "max", "clamped_to", "error"} var logFieldOrder = []string{"provider", "model", "mode", "budget", "level", "original_value", "original_level", "min", "max", "clamped_to", "error"}
// Format renders a single log entry with custom formatting. // Format renders a single log entry with custom formatting.
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) { func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {

View File

@@ -393,7 +393,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
} }
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, stream)
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream) payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
payload, err := thinking.ApplyThinking(payload, req.Model, "gemini") payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, translatedPayload{}, err return nil, translatedPayload{}, err
} }

View File

@@ -137,7 +137,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translated, err = thinking.ApplyThinking(translated, req.Model, "antigravity") translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -256,7 +256,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
translated, err = thinking.ApplyThinking(translated, req.Model, "antigravity") translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -622,7 +622,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
translated, err = thinking.ApplyThinking(translated, req.Model, "antigravity") translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -802,7 +802,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
// Prepare payload once (doesn't depend on baseURL) // Prepare payload once (doesn't depend on baseURL)
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
payload, err := thinking.ApplyThinking(payload, req.Model, "antigravity") payload, err := thinking.ApplyThinking(payload, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return cliproxyexecutor.Response{}, err return cliproxyexecutor.Response{}, err
} }

View File

@@ -106,7 +106,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream)
body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, "claude") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -239,7 +239,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, "claude") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -96,7 +96,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
body = sdktranslator.TranslateRequest(from, to, baseModel, body, false) body = sdktranslator.TranslateRequest(from, to, baseModel, body, false)
body = misc.StripCodexUserAgent(body) body = misc.StripCodexUserAgent(body)
body, err = thinking.ApplyThinking(body, req.Model, "codex") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -208,7 +208,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
body = sdktranslator.TranslateRequest(from, to, baseModel, body, true) body = sdktranslator.TranslateRequest(from, to, baseModel, body, true)
body = misc.StripCodexUserAgent(body) body = misc.StripCodexUserAgent(body)
body, err = thinking.ApplyThinking(body, req.Model, "codex") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -316,7 +316,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
body = sdktranslator.TranslateRequest(from, to, baseModel, body, false) body = sdktranslator.TranslateRequest(from, to, baseModel, body, false)
body = misc.StripCodexUserAgent(body) body = misc.StripCodexUserAgent(body)
body, err := thinking.ApplyThinking(body, req.Model, "codex") body, err := thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return cliproxyexecutor.Response{}, err return cliproxyexecutor.Response{}, err
} }

View File

@@ -123,7 +123,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
basePayload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) basePayload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
basePayload, err = thinking.ApplyThinking(basePayload, req.Model, "gemini-cli") basePayload, err = thinking.ApplyThinking(basePayload, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -272,7 +272,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
basePayload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) basePayload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
basePayload, err = thinking.ApplyThinking(basePayload, req.Model, "gemini-cli") basePayload, err = thinking.ApplyThinking(basePayload, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -479,7 +479,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
for range models { for range models {
payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) payload := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
payload, err = thinking.ApplyThinking(payload, req.Model, "gemini-cli") payload, err = thinking.ApplyThinking(payload, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return cliproxyexecutor.Response{}, err return cliproxyexecutor.Response{}, err
} }

View File

@@ -120,7 +120,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body, err = thinking.ApplyThinking(body, req.Model, "gemini") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -222,7 +222,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body, err = thinking.ApplyThinking(body, req.Model, "gemini") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -338,7 +338,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
to := sdktranslator.FromString("gemini") to := sdktranslator.FromString("gemini")
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, "gemini") translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return cliproxyexecutor.Response{}, err return cliproxyexecutor.Response{}, err
} }

View File

@@ -170,7 +170,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body, err = thinking.ApplyThinking(body, req.Model, "gemini") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -272,7 +272,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body, err = thinking.ApplyThinking(body, req.Model, "gemini") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -375,7 +375,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body, err = thinking.ApplyThinking(body, req.Model, "gemini") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -494,7 +494,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body, err = thinking.ApplyThinking(body, req.Model, "gemini") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -605,7 +605,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, "gemini") translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return cliproxyexecutor.Response{}, err return cliproxyexecutor.Response{}, err
} }
@@ -689,7 +689,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, "gemini") translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return cliproxyexecutor.Response{}, err return cliproxyexecutor.Response{}, err
} }

View File

@@ -92,7 +92,7 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, "iflow") body, err = thinking.ApplyThinking(body, req.Model, from.String(), "iflow")
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -190,7 +190,7 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, "iflow") body, err = thinking.ApplyThinking(body, req.Model, from.String(), "iflow")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -92,7 +92,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), opts.Stream) translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), opts.Stream)
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated) translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated)
translated, err = thinking.ApplyThinking(translated, req.Model, "openai") translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -187,7 +187,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) translated := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated) translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated)
translated, err = thinking.ApplyThinking(translated, req.Model, "openai") translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -297,7 +297,7 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau
modelForCounting := baseModel modelForCounting := baseModel
translated, err := thinking.ApplyThinking(translated, req.Model, "openai") translated, err := thinking.ApplyThinking(translated, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return cliproxyexecutor.Response{}, err return cliproxyexecutor.Response{}, err
} }

View File

@@ -86,7 +86,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, "openai") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return resp, err return resp, err
} }
@@ -172,7 +172,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
body, _ = sjson.SetBytes(body, "model", baseModel) body, _ = sjson.SetBytes(body, "model", baseModel)
body, err = thinking.ApplyThinking(body, req.Model, "openai") body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -2,6 +2,8 @@
package thinking package thinking
import ( import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
@@ -59,7 +61,8 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool {
// Parameters: // Parameters:
// - body: Original request body JSON // - body: Original request body JSON
// - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)") // - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)")
// - provider: Provider name (gemini, gemini-cli, antigravity, claude, openai, codex, iflow) // - fromFormat: Source request format (e.g., openai, codex, gemini)
// - toFormat: Target provider format for the request body (gemini, gemini-cli, antigravity, claude, openai, codex, iflow)
// //
// Returns: // Returns:
// - Modified request body JSON with thinking configuration applied // - Modified request body JSON with thinking configuration applied
@@ -76,16 +79,21 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool {
// Example: // Example:
// //
// // With suffix - suffix config takes priority // // With suffix - suffix config takes priority
// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro(8192)", "gemini") // result, err := thinking.ApplyThinking(body, "gemini-2.5-pro(8192)", "gemini", "gemini")
// //
// // Without suffix - uses body config // // Without suffix - uses body config
// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini") // result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini", "gemini")
func ApplyThinking(body []byte, model string, provider string) ([]byte, error) { func ApplyThinking(body []byte, model string, fromFormat string, toFormat string) ([]byte, error) {
providerFormat := strings.ToLower(strings.TrimSpace(toFormat))
fromFormat = strings.ToLower(strings.TrimSpace(fromFormat))
if fromFormat == "" {
fromFormat = providerFormat
}
// 1. Route check: Get provider applier // 1. Route check: Get provider applier
applier := GetProviderApplier(provider) applier := GetProviderApplier(providerFormat)
if applier == nil { if applier == nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": providerFormat,
"model": model, "model": model,
}).Debug("thinking: unknown provider, passthrough |") }).Debug("thinking: unknown provider, passthrough |")
return body, nil return body, nil
@@ -100,19 +108,19 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
// Unknown models are treated as user-defined so thinking config can still be applied. // Unknown models are treated as user-defined so thinking config can still be applied.
// The upstream service is responsible for validating the configuration. // The upstream service is responsible for validating the configuration.
if IsUserDefinedModel(modelInfo) { if IsUserDefinedModel(modelInfo) {
return applyUserDefinedModel(body, modelInfo, provider, suffixResult) return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult)
} }
if modelInfo.Thinking == nil { if modelInfo.Thinking == nil {
config := extractThinkingConfig(body, provider) config := extractThinkingConfig(body, providerFormat)
if hasThinkingConfig(config) { if hasThinkingConfig(config) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"model": baseModel, "model": baseModel,
"provider": provider, "provider": providerFormat,
}).Debug("thinking: model does not support thinking, stripping config |") }).Debug("thinking: model does not support thinking, stripping config |")
return StripThinkingConfig(body, provider), nil return StripThinkingConfig(body, providerFormat), nil
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": providerFormat,
"model": baseModel, "model": baseModel,
}).Debug("thinking: model does not support thinking, passthrough |") }).Debug("thinking: model does not support thinking, passthrough |")
return body, nil return body, nil
@@ -121,19 +129,19 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
// 4. Get config: suffix priority over body // 4. Get config: suffix priority over body
var config ThinkingConfig var config ThinkingConfig
if suffixResult.HasSuffix { if suffixResult.HasSuffix {
config = parseSuffixToConfig(suffixResult.RawSuffix, provider, model) config = parseSuffixToConfig(suffixResult.RawSuffix, providerFormat, model)
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": providerFormat,
"model": model, "model": model,
"mode": config.Mode, "mode": config.Mode,
"budget": config.Budget, "budget": config.Budget,
"level": config.Level, "level": config.Level,
}).Debug("thinking: config from model suffix |") }).Debug("thinking: config from model suffix |")
} else { } else {
config = extractThinkingConfig(body, provider) config = extractThinkingConfig(body, providerFormat)
if hasThinkingConfig(config) { if hasThinkingConfig(config) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": providerFormat,
"model": modelInfo.ID, "model": modelInfo.ID,
"mode": config.Mode, "mode": config.Mode,
"budget": config.Budget, "budget": config.Budget,
@@ -144,17 +152,17 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
if !hasThinkingConfig(config) { if !hasThinkingConfig(config) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": providerFormat,
"model": modelInfo.ID, "model": modelInfo.ID,
}).Debug("thinking: no config found, passthrough |") }).Debug("thinking: no config found, passthrough |")
return body, nil return body, nil
} }
// 5. Validate and normalize configuration // 5. Validate and normalize configuration
validated, err := ValidateConfig(config, modelInfo, provider) validated, err := ValidateConfig(config, modelInfo, fromFormat, providerFormat)
if err != nil { if err != nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": providerFormat,
"model": modelInfo.ID, "model": modelInfo.ID,
"error": err.Error(), "error": err.Error(),
}).Warn("thinking: validation failed |") }).Warn("thinking: validation failed |")
@@ -167,14 +175,14 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) {
// Defensive check: ValidateConfig should never return (nil, nil) // Defensive check: ValidateConfig should never return (nil, nil)
if validated == nil { if validated == nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": providerFormat,
"model": modelInfo.ID, "model": modelInfo.ID,
}).Warn("thinking: ValidateConfig returned nil config without error, passthrough |") }).Warn("thinking: ValidateConfig returned nil config without error, passthrough |")
return body, nil return body, nil
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": providerFormat,
"model": modelInfo.ID, "model": modelInfo.ID,
"mode": validated.Mode, "mode": validated.Mode,
"budget": validated.Budget, "budget": validated.Budget,
@@ -228,7 +236,7 @@ func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig {
// applyUserDefinedModel applies thinking configuration for user-defined models // applyUserDefinedModel applies thinking configuration for user-defined models
// without ThinkingSupport validation. // without ThinkingSupport validation.
func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, provider string, suffixResult SuffixResult) ([]byte, error) { func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat string, suffixResult SuffixResult) ([]byte, error) {
// Get model ID for logging // Get model ID for logging
modelID := "" modelID := ""
if modelInfo != nil { if modelInfo != nil {
@@ -240,39 +248,57 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, provider
// Get config: suffix priority over body // Get config: suffix priority over body
var config ThinkingConfig var config ThinkingConfig
if suffixResult.HasSuffix { if suffixResult.HasSuffix {
config = parseSuffixToConfig(suffixResult.RawSuffix, provider, modelID) config = parseSuffixToConfig(suffixResult.RawSuffix, toFormat, modelID)
} else { } else {
config = extractThinkingConfig(body, provider) config = extractThinkingConfig(body, toFormat)
} }
if !hasThinkingConfig(config) { if !hasThinkingConfig(config) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"model": modelID, "model": modelID,
"provider": provider, "provider": toFormat,
}).Debug("thinking: user-defined model, passthrough (no config) |") }).Debug("thinking: user-defined model, passthrough (no config) |")
return body, nil return body, nil
} }
applier := GetProviderApplier(provider) applier := GetProviderApplier(toFormat)
if applier == nil { if applier == nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"model": modelID, "model": modelID,
"provider": provider, "provider": toFormat,
}).Debug("thinking: user-defined model, passthrough (unknown provider) |") }).Debug("thinking: user-defined model, passthrough (unknown provider) |")
return body, nil return body, nil
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": toFormat,
"model": modelID, "model": modelID,
"mode": config.Mode, "mode": config.Mode,
"budget": config.Budget, "budget": config.Budget,
"level": config.Level, "level": config.Level,
}).Debug("thinking: applying config for user-defined model (skip validation)") }).Debug("thinking: applying config for user-defined model (skip validation)")
config = normalizeUserDefinedConfig(config, fromFormat, toFormat)
return applier.Apply(body, config, modelInfo) return applier.Apply(body, config, modelInfo)
} }
func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat string) ThinkingConfig {
if config.Mode != ModeLevel {
return config
}
if !isBudgetBasedProvider(toFormat) || !isLevelBasedProvider(fromFormat) {
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. // extractThinkingConfig extracts provider-specific thinking config from request body.
func extractThinkingConfig(body []byte, provider string) ThinkingConfig { func extractThinkingConfig(body []byte, provider string) ThinkingConfig {
if len(body) == 0 || !gjson.ValidBytes(body) { if len(body) == 0 || !gjson.ValidBytes(body) {
@@ -289,7 +315,11 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig {
case "codex": case "codex":
return extractCodexConfig(body) return extractCodexConfig(body)
case "iflow": case "iflow":
return extractIFlowConfig(body) config := extractIFlowConfig(body)
if hasThinkingConfig(config) {
return config
}
return extractOpenAIConfig(body)
default: default:
return ThinkingConfig{} return ThinkingConfig{}
} }

View File

@@ -24,6 +24,10 @@ const (
// Example: using level with a budget-only model // Example: using level with a budget-only model
ErrLevelNotSupported ErrorCode = "LEVEL_NOT_SUPPORTED" 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. // ErrProviderMismatch indicates the provider does not match the model.
// Example: applying Claude format to a Gemini model // Example: applying Claude format to a Gemini model
ErrProviderMismatch ErrorCode = "PROVIDER_MISMATCH" ErrProviderMismatch ErrorCode = "PROVIDER_MISMATCH"

View File

@@ -27,28 +27,32 @@ func StripThinkingConfig(body []byte, provider string) []byte {
return body return body
} }
var paths []string
switch provider { switch provider {
case "claude": case "claude":
result, _ := sjson.DeleteBytes(body, "thinking") paths = []string{"thinking"}
return result
case "gemini": case "gemini":
result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig") paths = []string{"generationConfig.thinkingConfig"}
return result
case "gemini-cli", "antigravity": case "gemini-cli", "antigravity":
result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig") paths = []string{"request.generationConfig.thinkingConfig"}
return result
case "openai": case "openai":
result, _ := sjson.DeleteBytes(body, "reasoning_effort") paths = []string{"reasoning_effort"}
return result
case "codex": case "codex":
result, _ := sjson.DeleteBytes(body, "reasoning.effort") paths = []string{"reasoning.effort"}
return result
case "iflow": case "iflow":
result, _ := sjson.DeleteBytes(body, "chat_template_kwargs.enable_thinking") paths = []string{
result, _ = sjson.DeleteBytes(result, "chat_template_kwargs.clear_thinking") "chat_template_kwargs.enable_thinking",
result, _ = sjson.DeleteBytes(result, "reasoning_split") "chat_template_kwargs.clear_thinking",
return result "reasoning_split",
"reasoning_effort",
}
default: default:
return body return body
} }
result := body
for _, path := range paths {
result, _ = sjson.DeleteBytes(result, path)
}
return result
} }

View File

@@ -9,64 +9,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// ClampBudget clamps a budget value to the model's supported range.
//
// Logging:
// - Warn when value=0 but ZeroAllowed=false
// - Debug when value is clamped to min/max
//
// Fields: provider, model, original_value, clamped_to, min, max
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 := support.Min
max := 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
}
// ValidateConfig validates a thinking configuration against model capabilities. // ValidateConfig validates a thinking configuration against model capabilities.
// //
// This function performs comprehensive validation: // This function performs comprehensive validation:
@@ -74,10 +16,14 @@ func ClampBudget(value int, modelInfo *registry.ModelInfo, provider string) int
// - Auto-converts between Budget and Level formats based on model capability // - Auto-converts between Budget and Level formats based on model capability
// - Validates that requested level is in the model's supported levels list // - Validates that requested level is in the model's supported levels list
// - Clamps budget values to model's allowed range // - 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)
// //
// Parameters: // Parameters:
// - config: The thinking configuration to validate // - config: The thinking configuration to validate
// - support: Model's ThinkingSupport properties (nil means no thinking support) // - support: Model's ThinkingSupport properties (nil means no thinking support)
// - fromFormat: Source provider format (used to determine strict validation rules)
// - toFormat: Target provider format
// //
// Returns: // Returns:
// - Normalized ThinkingConfig with clamped values // - Normalized ThinkingConfig with clamped values
@@ -87,9 +33,8 @@ func ClampBudget(value int, modelInfo *registry.ModelInfo, provider string) int
// - Budget-only model + Level config → Level converted to Budget // - Budget-only model + Level config → Level converted to Budget
// - Level-only model + Budget config → Budget converted to Level // - Level-only model + Budget config → Budget converted to Level
// - Hybrid model → preserve original format // - Hybrid model → preserve original format
func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provider string) (*ThinkingConfig, error) { func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFormat, toFormat string) (*ThinkingConfig, error) {
normalized := config fromFormat, toFormat = strings.ToLower(strings.TrimSpace(fromFormat)), strings.ToLower(strings.TrimSpace(toFormat))
model := "unknown" model := "unknown"
support := (*registry.ThinkingSupport)(nil) support := (*registry.ThinkingSupport)(nil)
if modelInfo != nil { if modelInfo != nil {
@@ -103,101 +48,108 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provid
if config.Mode != ModeNone { if config.Mode != ModeNone {
return nil, NewThinkingErrorWithModel(ErrThinkingNotSupported, "thinking not supported for this model", model) return nil, NewThinkingErrorWithModel(ErrThinkingNotSupported, "thinking not supported for this model", model)
} }
return &normalized, nil return &config, nil
} }
allowClampUnsupported := isBudgetBasedProvider(fromFormat) && isLevelBasedProvider(toFormat)
strictBudget := fromFormat != "" && isSameProviderFamily(fromFormat, toFormat)
budgetDerivedFromLevel := false
capability := detectModelCapability(modelInfo) capability := detectModelCapability(modelInfo)
switch capability { switch capability {
case CapabilityBudgetOnly: case CapabilityBudgetOnly:
if normalized.Mode == ModeLevel { if config.Mode == ModeLevel {
if normalized.Level == LevelAuto { if config.Level == LevelAuto {
break break
} }
budget, ok := ConvertLevelToBudget(string(normalized.Level)) budget, ok := ConvertLevelToBudget(string(config.Level))
if !ok { if !ok {
return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("unknown level: %s", normalized.Level)) return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("unknown level: %s", config.Level))
} }
normalized.Mode = ModeBudget config.Mode = ModeBudget
normalized.Budget = budget config.Budget = budget
normalized.Level = "" config.Level = ""
budgetDerivedFromLevel = true
} }
case CapabilityLevelOnly: case CapabilityLevelOnly:
if normalized.Mode == ModeBudget { if config.Mode == ModeBudget {
level, ok := ConvertBudgetToLevel(normalized.Budget) level, ok := ConvertBudgetToLevel(config.Budget)
if !ok { if !ok {
return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("budget %d cannot be converted to a valid level", normalized.Budget)) return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("budget %d cannot be converted to a valid level", config.Budget))
} }
normalized.Mode = ModeLevel // When converting Budget -> Level for level-only models, clamp the derived standard level
normalized.Level = ThinkingLevel(level) // to the nearest supported level. Special values (none/auto) are preserved.
normalized.Budget = 0 config.Mode = ModeLevel
config.Level = clampLevel(ThinkingLevel(level), modelInfo, toFormat)
config.Budget = 0
} }
case CapabilityHybrid: case CapabilityHybrid:
} }
if normalized.Mode == ModeLevel && normalized.Level == LevelNone { if config.Mode == ModeLevel && config.Level == LevelNone {
normalized.Mode = ModeNone config.Mode = ModeNone
normalized.Budget = 0 config.Budget = 0
normalized.Level = "" config.Level = ""
} }
if normalized.Mode == ModeLevel && normalized.Level == LevelAuto { if config.Mode == ModeLevel && config.Level == LevelAuto {
normalized.Mode = ModeAuto config.Mode = ModeAuto
normalized.Budget = -1 config.Budget = -1
normalized.Level = "" config.Level = ""
} }
if normalized.Mode == ModeBudget && normalized.Budget == 0 { if config.Mode == ModeBudget && config.Budget == 0 {
normalized.Mode = ModeNone config.Mode = ModeNone
normalized.Level = "" config.Level = ""
} }
if len(support.Levels) > 0 && normalized.Mode == ModeLevel { if len(support.Levels) > 0 && config.Mode == ModeLevel {
if !isLevelSupported(string(normalized.Level), support.Levels) { if !isLevelSupported(string(config.Level), support.Levels) {
validLevels := normalizeLevels(support.Levels) if allowClampUnsupported {
message := fmt.Sprintf("level %q not supported, valid levels: %s", strings.ToLower(string(normalized.Level)), strings.Join(validLevels, ", ")) config.Level = clampLevel(config.Level, modelInfo, toFormat)
return nil, NewThinkingError(ErrLevelNotSupported, message) }
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 // Convert ModeAuto to mid-range if dynamic not allowed
if normalized.Mode == ModeAuto && !support.DynamicAllowed { if config.Mode == ModeAuto && !support.DynamicAllowed {
normalized = convertAutoToMidRange(normalized, support, provider, model) config = convertAutoToMidRange(config, support, toFormat, model)
} }
if normalized.Mode == ModeNone && provider == "claude" { if config.Mode == ModeNone && toFormat == "claude" {
// Claude supports explicit disable via thinking.type="disabled". // Claude supports explicit disable via thinking.type="disabled".
// Keep Budget=0 so applier can omit budget_tokens. // Keep Budget=0 so applier can omit budget_tokens.
normalized.Budget = 0 config.Budget = 0
normalized.Level = "" config.Level = ""
} else { } else {
switch normalized.Mode { switch config.Mode {
case ModeBudget, ModeAuto, ModeNone: case ModeBudget, ModeAuto, ModeNone:
normalized.Budget = ClampBudget(normalized.Budget, modelInfo, provider) config.Budget = clampBudget(config.Budget, modelInfo, toFormat)
} }
// ModeNone with clamped Budget > 0: set Level to lowest for Level-only/Hybrid models // ModeNone with clamped Budget > 0: set Level to lowest for Level-only/Hybrid models
// This ensures Apply layer doesn't need to access support.Levels // This ensures Apply layer doesn't need to access support.Levels
if normalized.Mode == ModeNone && normalized.Budget > 0 && len(support.Levels) > 0 { if config.Mode == ModeNone && config.Budget > 0 && len(support.Levels) > 0 {
normalized.Level = ThinkingLevel(support.Levels[0]) config.Level = ThinkingLevel(support.Levels[0])
} }
} }
return &normalized, nil return &config, nil
}
func isLevelSupported(level string, supported []string) bool {
for _, candidate := range supported {
if strings.EqualFold(level, strings.TrimSpace(candidate)) {
return true
}
}
return false
}
func normalizeLevels(levels []string) []string {
normalized := make([]string, 0, len(levels))
for _, level := range levels {
normalized = append(normalized, strings.ToLower(strings.TrimSpace(level)))
}
return normalized
} }
// convertAutoToMidRange converts ModeAuto to a mid-range value when dynamic is not allowed. // convertAutoToMidRange converts ModeAuto to a mid-range value when dynamic is not allowed.
@@ -246,7 +198,172 @@ func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupp
return config return config
} }
// logClamp logs a debug message when budget clamping occurs. // standardLevelOrder defines the canonical ordering of thinking levels from lowest to highest.
var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh}
// 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_level": 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
}
func isBudgetBasedProvider(provider string) bool {
switch provider {
case "gemini", "gemini-cli", "antigravity", "claude":
return true
default:
return false
}
}
func isLevelBasedProvider(provider string) bool {
switch provider {
case "openai", "openai-response", "codex":
return true
default:
return false
}
}
func isGeminiFamily(provider string) bool {
switch provider {
case "gemini", "gemini-cli", "antigravity":
return true
default:
return false
}
}
func isSameProviderFamily(from, to string) bool {
if from == to {
return true
}
return isGeminiFamily(from) && isGeminiFamily(to)
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func logClamp(provider, model string, original, clampedTo, min, max int) { func logClamp(provider, model string, original, clampedTo, min, max int) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"provider": provider, "provider": provider,

View File

@@ -12,7 +12,6 @@ import (
"strings" "strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache" "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
@@ -388,14 +387,11 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
modelInfo := registry.LookupModelInfo(modelName) if t.Get("type").String() == "enabled" {
if modelInfo != nil && modelInfo.Thinking != nil { if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
if t.Get("type").String() == "enabled" { budget := int(b.Int())
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
budget := int(b.Int()) out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
}
} }
} }
} }

View File

@@ -343,8 +343,8 @@ func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) {
if thinkingConfig.Get("thinkingBudget").Int() != 8000 { if thinkingConfig.Get("thinkingBudget").Int() != 8000 {
t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int()) t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int())
} }
if !thinkingConfig.Get("include_thoughts").Bool() { if !thinkingConfig.Get("includeThoughts").Bool() {
t.Error("include_thoughts should be true") t.Error("includeThoughts should be true")
} }
} else { } else {
t.Log("thinkingConfig not present - model may not be registered in test registry") t.Log("thinkingConfig not present - model may not be registered in test registry")

View File

@@ -15,7 +15,7 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
@@ -115,18 +115,41 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
} }
} }
// Include thoughts configuration for reasoning process visibility // Include thoughts configuration for reasoning process visibility
// Only apply for models that support thinking and use numeric budgets, not discrete levels. // Translator only does format conversion, ApplyThinking handles model capability validation.
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
modelInfo := registry.LookupModelInfo(modelName) if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 { level := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
// Check for thinkingBudget first - if present, enable thinking with budget switch level {
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() && thinkingBudget.Int() > 0 { case "":
out, _ = sjson.Set(out, "thinking.type", "enabled") case "none":
out, _ = sjson.Set(out, "thinking.budget_tokens", thinkingBudget.Int()) out, _ = sjson.Set(out, "thinking.type", "disabled")
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { out, _ = sjson.Delete(out, "thinking.budget_tokens")
// Fallback to include_thoughts if no budget specified case "auto":
out, _ = sjson.Set(out, "thinking.type", "enabled") out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
default:
if budget, ok := thinking.ConvertLevelToBudget(level); ok {
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
} }
} else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
budget := int(thinkingBudget.Int())
switch budget {
case 0:
out, _ = sjson.Set(out, "thinking.type", "disabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
case -1:
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
default:
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
} else if includeThoughts := thinkingConfig.Get("includeThoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
out, _ = sjson.Set(out, "thinking.type", "enabled")
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
out, _ = sjson.Set(out, "thinking.type", "enabled")
} }
} }
} }

View File

@@ -15,7 +15,6 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
@@ -66,23 +65,21 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
root := gjson.ParseBytes(rawJSON) root := gjson.ParseBytes(rawJSON)
// Convert OpenAI reasoning_effort to Claude thinking config.
if v := root.Get("reasoning_effort"); v.Exists() { if v := root.Get("reasoning_effort"); v.Exists() {
modelInfo := registry.LookupModelInfo(modelName) effort := strings.ToLower(strings.TrimSpace(v.String()))
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 { if effort != "" {
effort := strings.ToLower(strings.TrimSpace(v.String())) budget, ok := thinking.ConvertLevelToBudget(effort)
if effort != "" { if ok {
budget, ok := thinking.ConvertLevelToBudget(effort) switch budget {
if ok { case 0:
switch budget { out, _ = sjson.Set(out, "thinking.type", "disabled")
case 0: case -1:
out, _ = sjson.Set(out, "thinking.type", "disabled") out, _ = sjson.Set(out, "thinking.type", "enabled")
case -1: default:
if budget > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled") out, _ = sjson.Set(out, "thinking.type", "enabled")
default: out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
if budget > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
} }
} }
} }

View File

@@ -10,7 +10,6 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
@@ -54,23 +53,21 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
root := gjson.ParseBytes(rawJSON) root := gjson.ParseBytes(rawJSON)
// Convert OpenAI Responses reasoning.effort to Claude thinking config.
if v := root.Get("reasoning.effort"); v.Exists() { if v := root.Get("reasoning.effort"); v.Exists() {
modelInfo := registry.LookupModelInfo(modelName) effort := strings.ToLower(strings.TrimSpace(v.String()))
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 { if effort != "" {
effort := strings.ToLower(strings.TrimSpace(v.String())) budget, ok := thinking.ConvertLevelToBudget(effort)
if effort != "" { if ok {
budget, ok := thinking.ConvertLevelToBudget(effort) switch budget {
if ok { case 0:
switch budget { out, _ = sjson.Set(out, "thinking.type", "disabled")
case 0: case -1:
out, _ = sjson.Set(out, "thinking.type", "disabled") out, _ = sjson.Set(out, "thinking.type", "enabled")
case -1: default:
if budget > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled") out, _ = sjson.Set(out, "thinking.type", "enabled")
default: out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
if budget > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
} }
} }
} }

View File

@@ -12,7 +12,6 @@ import (
"strings" "strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
@@ -218,18 +217,15 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
// Add additional configuration parameters for the Codex API. // Add additional configuration parameters for the Codex API.
template, _ = sjson.Set(template, "parallel_tool_calls", true) template, _ = sjson.Set(template, "parallel_tool_calls", true)
// Convert thinking.budget_tokens to reasoning.effort for level-based models // Convert thinking.budget_tokens to reasoning.effort.
reasoningEffort := "medium" // default reasoningEffort := "medium"
if thinkingConfig := rootResult.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() { if thinkingConfig := rootResult.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
modelInfo := registry.LookupModelInfo(modelName)
switch thinkingConfig.Get("type").String() { switch thinkingConfig.Get("type").String() {
case "enabled": case "enabled":
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 { if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() {
if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() { budget := int(budgetTokens.Int())
budget := int(budgetTokens.Int()) if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" { reasoningEffort = effort
reasoningEffort = effort
}
} }
} }
case "disabled": case "disabled":

View File

@@ -14,7 +14,6 @@ import (
"strings" "strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
@@ -249,22 +248,28 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
// Fixed flags aligning with Codex expectations // Fixed flags aligning with Codex expectations
out, _ = sjson.Set(out, "parallel_tool_calls", true) out, _ = sjson.Set(out, "parallel_tool_calls", true)
// Convert thinkingBudget to reasoning.effort for level-based models // Convert Gemini thinkingConfig to Codex reasoning.effort.
reasoningEffort := "medium" // default effortSet := false
if genConfig := root.Get("generationConfig"); genConfig.Exists() { if genConfig := root.Get("generationConfig"); genConfig.Exists() {
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
modelInfo := registry.LookupModelInfo(modelName) if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 { effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { if effort != "" {
budget := int(thinkingBudget.Int()) out, _ = sjson.Set(out, "reasoning.effort", effort)
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" { effortSet = true
reasoningEffort = effort }
} } else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
out, _ = sjson.Set(out, "reasoning.effort", effort)
effortSet = true
} }
} }
} }
} }
out, _ = sjson.Set(out, "reasoning.effort", reasoningEffort) if !effortSet {
// No thinking config, set default effort
out, _ = sjson.Set(out, "reasoning.effort", "medium")
}
out, _ = sjson.Set(out, "reasoning.summary", "auto") out, _ = sjson.Set(out, "reasoning.summary", "auto")
out, _ = sjson.Set(out, "stream", true) out, _ = sjson.Set(out, "stream", true)
out, _ = sjson.Set(out, "store", false) out, _ = sjson.Set(out, "store", false)

View File

@@ -9,7 +9,6 @@ import (
"bytes" "bytes"
"strings" "strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
@@ -161,14 +160,11 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
modelInfo := registry.LookupModelInfo(modelName) if t.Get("type").String() == "enabled" {
if modelInfo != nil && modelInfo.Thinking != nil { if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
if t.Get("type").String() == "enabled" { budget := int(b.Int())
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
budget := int(b.Int()) out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
}
} }
} }
} }

View File

@@ -9,7 +9,6 @@ import (
"bytes" "bytes"
"strings" "strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
@@ -153,16 +152,13 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
} }
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
// Only apply for models that use numeric budgets, not discrete levels. // Translator only does format conversion, ApplyThinking handles model capability validation.
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
modelInfo := registry.LookupModelInfo(modelName) if t.Get("type").String() == "enabled" {
if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 { if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
if t.Get("type").String() == "enabled" { budget := int(b.Int())
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
budget := int(b.Int()) out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
}
} }
} }
} }

View File

@@ -77,12 +77,15 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
} }
} }
// Convert thinkingBudget to reasoning_effort // Map Gemini thinkingConfig to OpenAI reasoning_effort.
// Always perform conversion to support allowCompat models that may not be in registry
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() {
budget := int(thinkingBudget.Int()) effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" { if effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
} else if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
out, _ = sjson.Set(out, "reasoning_effort", effort) out, _ = sjson.Set(out, "reasoning_effort", effort)
} }
} }

File diff suppressed because it is too large Load Diff