diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 010ed084..8a48438c 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -24,6 +24,7 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro" @@ -1707,7 +1708,7 @@ func (h *Handler) RequestQwenToken(c *gin.Context) { // Create token storage tokenStorage := qwenAuth.CreateTokenStorage(tokenData) - tokenStorage.Email = fmt.Sprintf("qwen-%d", time.Now().UnixMilli()) + tokenStorage.Email = fmt.Sprintf("%d", time.Now().UnixMilli()) record := &coreauth.Auth{ ID: fmt.Sprintf("qwen-%s.json", tokenStorage.Email), Provider: "qwen", @@ -1812,7 +1813,7 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) { tokenStorage := authSvc.CreateTokenStorage(tokenData) identifier := strings.TrimSpace(tokenStorage.Email) if identifier == "" { - identifier = fmt.Sprintf("iflow-%d", time.Now().UnixMilli()) + identifier = fmt.Sprintf("%d", time.Now().UnixMilli()) tokenStorage.Email = identifier } record := &coreauth.Auth{ @@ -1843,6 +1844,89 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state}) } +func (h *Handler) RequestGitHubToken(c *gin.Context) { + ctx := context.Background() + + fmt.Println("Initializing GitHub Copilot authentication...") + + state := fmt.Sprintf("gh-%d", time.Now().UnixNano()) + + // Initialize Copilot auth service + // We need to import "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" first if not present + // Assuming copilot package is imported as "copilot" + deviceClient := copilot.NewDeviceFlowClient(h.cfg) + + // Initiate device flow + deviceCode, err := deviceClient.RequestDeviceCode(ctx) + if err != nil { + log.Errorf("Failed to initiate device flow: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initiate device flow"}) + return + } + + authURL := deviceCode.VerificationURI + userCode := deviceCode.UserCode + + RegisterOAuthSession(state, "github") + + go func() { + fmt.Printf("Please visit %s and enter code: %s\n", authURL, userCode) + + tokenData, errPoll := deviceClient.PollForToken(ctx, deviceCode) + if errPoll != nil { + SetOAuthSessionError(state, "Authentication failed") + fmt.Printf("Authentication failed: %v\n", errPoll) + return + } + + username, errUser := deviceClient.FetchUserInfo(ctx, tokenData.AccessToken) + if errUser != nil { + log.Warnf("Failed to fetch user info: %v", errUser) + username = "github-user" + } + + tokenStorage := &copilot.CopilotTokenStorage{ + AccessToken: tokenData.AccessToken, + TokenType: tokenData.TokenType, + Scope: tokenData.Scope, + Username: username, + Type: "github-copilot", + } + + fileName := fmt.Sprintf("github-%s.json", username) + record := &coreauth.Auth{ + ID: fileName, + Provider: "github", + FileName: fileName, + Storage: tokenStorage, + Metadata: map[string]any{ + "email": username, + "username": username, + }, + } + + savedPath, errSave := h.saveTokenRecord(ctx, record) + if errSave != nil { + log.Errorf("Failed to save authentication tokens: %v", errSave) + SetOAuthSessionError(state, "Failed to save authentication tokens") + return + } + + fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) + fmt.Println("You can now use GitHub Copilot services through this CLI") + CompleteOAuthSession(state) + CompleteOAuthSessionsByProvider("github") + }() + + c.JSON(200, gin.H{ + "status": "ok", + "url": authURL, + "state": state, + "user_code": userCode, + "verification_uri": authURL, + }) +} + func (h *Handler) RequestIFlowCookieToken(c *gin.Context) { ctx := context.Background() @@ -1897,15 +1981,17 @@ func (h *Handler) RequestIFlowCookieToken(c *gin.Context) { fileName := iflowauth.SanitizeIFlowFileName(email) if fileName == "" { fileName = fmt.Sprintf("iflow-%d", time.Now().UnixMilli()) + } else { + fileName = fmt.Sprintf("iflow-%s", fileName) } tokenStorage.Email = email timestamp := time.Now().Unix() record := &coreauth.Auth{ - ID: fmt.Sprintf("iflow-%s-%d.json", fileName, timestamp), + ID: fmt.Sprintf("%s-%d.json", fileName, timestamp), Provider: "iflow", - FileName: fmt.Sprintf("iflow-%s-%d.json", fileName, timestamp), + FileName: fmt.Sprintf("%s-%d.json", fileName, timestamp), Storage: tokenStorage, Metadata: map[string]any{ "email": email, diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go index 2612318a..b64cd619 100644 --- a/internal/api/handlers/management/logs.go +++ b/internal/api/handlers/management/logs.go @@ -13,7 +13,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" ) const ( @@ -360,16 +360,7 @@ func (h *Handler) logDirectory() string { if h.logDir != "" { return h.logDir } - if base := util.WritablePath(); base != "" { - return filepath.Join(base, "logs") - } - if h.configFilePath != "" { - dir := filepath.Dir(h.configFilePath) - if dir != "" && dir != "." { - return filepath.Join(dir, "logs") - } - } - return "logs" + return logging.ResolveLogDirectory(h.cfg) } func (h *Handler) collectLogFiles(dir string) ([]string, error) { diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go index 08e047f5..bc882e99 100644 --- a/internal/api/handlers/management/oauth_sessions.go +++ b/internal/api/handlers/management/oauth_sessions.go @@ -238,6 +238,8 @@ func NormalizeOAuthProvider(provider string) (string, error) { return "qwen", nil case "kiro": return "kiro", nil + case "github": + return "github", nil default: return "", errUnsupportedOAuthFlow } diff --git a/internal/api/server.go b/internal/api/server.go index 40c41a94..9ad1ea50 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -262,10 +262,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk if optionState.localPassword != "" { s.mgmt.SetLocalPassword(optionState.localPassword) } - logDir := filepath.Join(s.currentPath, "logs") - if base := util.WritablePath(); base != "" { - logDir = filepath.Join(base, "logs") - } + logDir := logging.ResolveLogDirectory(cfg) s.mgmt.SetLogDirectory(logDir) s.localPassword = optionState.localPassword @@ -649,6 +646,7 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken) + mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken) mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) } diff --git a/internal/logging/global_logger.go b/internal/logging/global_logger.go index 158cca83..83adc6aa 100644 --- a/internal/logging/global_logger.go +++ b/internal/logging/global_logger.go @@ -30,7 +30,7 @@ var ( type LogFormatter struct{} // 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_mode", "original_value", "min", "max", "clamped_to", "error"} // Format renders a single log entry with custom formatting. func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) { @@ -122,6 +122,24 @@ func isDirWritable(dir string) bool { return true } +// ResolveLogDirectory determines the directory used for application logs. +func ResolveLogDirectory(cfg *config.Config) string { + logDir := "logs" + if base := util.WritablePath(); base != "" { + return filepath.Join(base, "logs") + } + if cfg == nil { + return logDir + } + if !isDirWritable(logDir) { + authDir := strings.TrimSpace(cfg.AuthDir) + if authDir != "" { + logDir = filepath.Join(authDir, "logs") + } + } + return logDir +} + // ConfigureLogOutput switches the global log destination between rotating files and stdout. // When logsMaxTotalSizeMB > 0, a background cleaner removes the oldest log files in the logs directory // until the total size is within the limit. @@ -131,12 +149,7 @@ func ConfigureLogOutput(cfg *config.Config) error { writerMu.Lock() defer writerMu.Unlock() - logDir := "logs" - if base := util.WritablePath(); base != "" { - logDir = filepath.Join(base, "logs") - } else if !isDirWritable(logDir) { - logDir = filepath.Join(cfg.AuthDir, "logs") - } + logDir := ResolveLogDirectory(cfg) protectedPath := "" if cfg.LoggingToFile { diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index d47e50a5..5ee9faae 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -765,21 +765,23 @@ func GetIFlowModels() []*ModelInfo { type AntigravityModelConfig struct { Thinking *ThinkingSupport MaxCompletionTokens int - Name string } // GetAntigravityModelConfig returns static configuration for antigravity models. // Keys use upstream model names returned by the Antigravity models endpoint. func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { return map[string]*AntigravityModelConfig{ - "gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"}, - "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash-lite"}, - "rev19-uic3-1p": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/rev19-uic3-1p"}, - "gemini-3-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-high"}, - "gemini-3-pro-image": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-image"}, - "gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, Name: "models/gemini-3-flash"}, + "gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}}, + "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}}, + "rev19-uic3-1p": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}}, + "gemini-3-pro-high": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}}, + "gemini-3-pro-image": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}}, + "gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}}, "claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, + "claude-sonnet-4-5": {MaxCompletionTokens: 64000}, + "gpt-oss-120b-medium": {}, + "tab_flash_lite_preview": {}, } } @@ -809,10 +811,9 @@ func LookupStaticModelInfo(modelID string) *ModelInfo { } // Check Antigravity static config - if cfg := GetAntigravityModelConfig()[modelID]; cfg != nil && cfg.Thinking != nil { + if cfg := GetAntigravityModelConfig()[modelID]; cfg != nil { return &ModelInfo{ ID: modelID, - Name: cfg.Name, Thinking: cfg.Thinking, MaxCompletionTokens: cfg.MaxCompletionTokens, } diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index fffb50c4..a020c670 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -393,7 +393,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c } originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return nil, translatedPayload{}, err } diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 47113cfc..df26e376 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -137,7 +137,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return resp, err } @@ -256,7 +256,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return resp, err } @@ -622,7 +622,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return nil, err } @@ -802,7 +802,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut // Prepare payload once (doesn't depend on baseURL) 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 { return cliproxyexecutor.Response{}, err } @@ -1005,9 +1005,6 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c } modelCfg := modelConfig[modelID] modelName := modelID - if modelCfg != nil && modelCfg.Name != "" { - modelName = modelCfg.Name - } modelInfo := ®istry.ModelInfo{ ID: modelID, Name: modelName, @@ -1410,13 +1407,6 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b template, _ = sjson.Delete(template, "request.safetySettings") template, _ = sjson.Set(template, "request.toolConfig.functionCallingConfig.mode", "VALIDATED") - if !strings.HasPrefix(modelName, "gemini-3-") { - if thinkingLevel := gjson.Get(template, "request.generationConfig.thinkingConfig.thinkingLevel"); thinkingLevel.Exists() { - template, _ = sjson.Delete(template, "request.generationConfig.thinkingConfig.thinkingLevel") - template, _ = sjson.Set(template, "request.generationConfig.thinkingConfig.thinkingBudget", -1) - } - } - if strings.Contains(modelName, "claude") { gjson.Get(template, "request.tools").ForEach(func(key, tool gjson.Result) bool { tool.Get("functionDeclarations").ForEach(func(funKey, funcDecl gjson.Result) bool { diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index b4cbd450..b6d5418a 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -17,7 +17,6 @@ import ( claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "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/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" @@ -106,7 +105,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream) 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 { return resp, err } @@ -119,9 +118,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) - // Ensure max_tokens > thinking.budget_tokens when thinking is enabled - body = ensureMaxTokensForThinking(baseModel, body) - // Extract betas from body and convert to header var extraBetas []string extraBetas, body = extractAndRemoveBetas(body) @@ -239,7 +235,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) 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 { return nil, err } @@ -250,9 +246,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Disable thinking if tool_choice forces tool use (Anthropic API constraint) body = disableThinkingIfToolChoiceForced(body) - // Ensure max_tokens > thinking.budget_tokens when thinking is enabled - body = ensureMaxTokensForThinking(baseModel, body) - // Extract betas from body and convert to header var extraBetas []string extraBetas, body = extractAndRemoveBetas(body) @@ -541,81 +534,6 @@ func disableThinkingIfToolChoiceForced(body []byte) []byte { return body } -// ensureMaxTokensForThinking ensures max_tokens > thinking.budget_tokens when thinking is enabled. -// Anthropic API requires this constraint; violating it returns a 400 error. -// This function should be called after all thinking configuration is finalized. -// It looks up the model's MaxCompletionTokens from the registry to use as the cap. -func ensureMaxTokensForThinking(modelName string, body []byte) []byte { - thinkingType := gjson.GetBytes(body, "thinking.type").String() - if thinkingType != "enabled" { - return body - } - - budgetTokens := gjson.GetBytes(body, "thinking.budget_tokens").Int() - if budgetTokens <= 0 { - return body - } - - maxTokens := gjson.GetBytes(body, "max_tokens").Int() - - // Look up the model's max completion tokens from the registry - maxCompletionTokens := 0 - if modelInfo := registry.LookupModelInfo(modelName); modelInfo != nil { - maxCompletionTokens = modelInfo.MaxCompletionTokens - } - - // Fall back to budget + buffer if registry lookup fails or returns 0 - const fallbackBuffer = 4000 - requiredMaxTokens := budgetTokens + fallbackBuffer - if maxCompletionTokens > 0 { - requiredMaxTokens = int64(maxCompletionTokens) - } - - if maxTokens < requiredMaxTokens { - body, _ = sjson.SetBytes(body, "max_tokens", requiredMaxTokens) - } - return body -} - -func (e *ClaudeExecutor) resolveClaudeConfig(auth *cliproxyauth.Auth) *config.ClaudeKey { - if auth == nil || e.cfg == nil { - return nil - } - var attrKey, attrBase string - if auth.Attributes != nil { - attrKey = strings.TrimSpace(auth.Attributes["api_key"]) - attrBase = strings.TrimSpace(auth.Attributes["base_url"]) - } - for i := range e.cfg.ClaudeKey { - entry := &e.cfg.ClaudeKey[i] - cfgKey := strings.TrimSpace(entry.APIKey) - cfgBase := strings.TrimSpace(entry.BaseURL) - if attrKey != "" && attrBase != "" { - if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) { - return entry - } - continue - } - if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { - if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { - return entry - } - } - if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { - return entry - } - } - if attrKey != "" { - for i := range e.cfg.ClaudeKey { - entry := &e.cfg.ClaudeKey[i] - if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) { - return entry - } - } - } - return nil -} - type compositeReadCloser struct { io.Reader closers []func() error diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index eeefe6bc..cc0e32a1 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -96,7 +96,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body = sdktranslator.TranslateRequest(from, to, baseModel, body, false) 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 { 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 = 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 { 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 = 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 { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index add01cb3..b23406af 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -123,7 +123,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return resp, err } @@ -272,7 +272,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return nil, err } @@ -479,7 +479,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth. for range models { 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 { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 4cc5d945..e9f9dbca 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -120,7 +120,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { 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) 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 { return nil, err } @@ -338,7 +338,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut to := sdktranslator.FromString("gemini") 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 { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 8a412b47..20e59b3f 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -170,7 +170,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return resp, err } @@ -272,7 +272,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return resp, err } @@ -375,7 +375,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { return nil, err } @@ -494,7 +494,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, 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 { 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, err := thinking.ApplyThinking(translatedReq, req.Model, "gemini") + translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String()) if err != nil { 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, err := thinking.ApplyThinking(translatedReq, req.Model, "gemini") + translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 6ce4221c..3e6ca4e5 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -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, _ = 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 { 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, _ = 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 { return nil, err } diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 6ae9103f..a2bef724 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -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 = 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 { 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 = 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 { return nil, err } @@ -297,7 +297,7 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau modelForCounting := baseModel - translated, err := thinking.ApplyThinking(translated, req.Model, "openai") + translated, err := thinking.ApplyThinking(translated, req.Model, from.String(), to.String()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/qwen_executor.go b/internal/runtime/executor/qwen_executor.go index ff35c935..260165d9 100644 --- a/internal/runtime/executor/qwen_executor.go +++ b/internal/runtime/executor/qwen_executor.go @@ -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, _ = 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 { 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, _ = 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 { return nil, err } diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 003405c0..cf0e373b 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -2,6 +2,8 @@ package thinking import ( + "strings" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" @@ -59,7 +61,8 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool { // Parameters: // - body: Original request body JSON // - 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: // - Modified request body JSON with thinking configuration applied @@ -76,16 +79,21 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool { // Example: // // // 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 -// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini") -func ApplyThinking(body []byte, model string, provider string) ([]byte, error) { +// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini", "gemini") +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 - applier := GetProviderApplier(provider) + applier := GetProviderApplier(providerFormat) if applier == nil { log.WithFields(log.Fields{ - "provider": provider, + "provider": providerFormat, "model": model, }).Debug("thinking: unknown provider, passthrough |") 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. // The upstream service is responsible for validating the configuration. if IsUserDefinedModel(modelInfo) { - return applyUserDefinedModel(body, modelInfo, provider, suffixResult) + return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, suffixResult) } if modelInfo.Thinking == nil { - config := extractThinkingConfig(body, provider) + config := extractThinkingConfig(body, providerFormat) if hasThinkingConfig(config) { log.WithFields(log.Fields{ "model": baseModel, - "provider": provider, + "provider": providerFormat, }).Debug("thinking: model does not support thinking, stripping config |") - return StripThinkingConfig(body, provider), nil + return StripThinkingConfig(body, providerFormat), nil } log.WithFields(log.Fields{ - "provider": provider, + "provider": providerFormat, "model": baseModel, }).Debug("thinking: model does not support thinking, passthrough |") return body, nil @@ -121,19 +129,19 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) { // 4. Get config: suffix priority over body var config ThinkingConfig if suffixResult.HasSuffix { - config = parseSuffixToConfig(suffixResult.RawSuffix, provider, model) + config = parseSuffixToConfig(suffixResult.RawSuffix, providerFormat, model) log.WithFields(log.Fields{ - "provider": provider, + "provider": providerFormat, "model": model, "mode": config.Mode, "budget": config.Budget, "level": config.Level, }).Debug("thinking: config from model suffix |") } else { - config = extractThinkingConfig(body, provider) + config = extractThinkingConfig(body, providerFormat) if hasThinkingConfig(config) { log.WithFields(log.Fields{ - "provider": provider, + "provider": providerFormat, "model": modelInfo.ID, "mode": config.Mode, "budget": config.Budget, @@ -144,17 +152,17 @@ func ApplyThinking(body []byte, model string, provider string) ([]byte, error) { if !hasThinkingConfig(config) { log.WithFields(log.Fields{ - "provider": provider, + "provider": providerFormat, "model": modelInfo.ID, }).Debug("thinking: no config found, passthrough |") return body, nil } // 5. Validate and normalize configuration - validated, err := ValidateConfig(config, modelInfo, provider) + validated, err := ValidateConfig(config, modelInfo, fromFormat, providerFormat, suffixResult.HasSuffix) if err != nil { log.WithFields(log.Fields{ - "provider": provider, + "provider": providerFormat, "model": modelInfo.ID, "error": err.Error(), }).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) if validated == nil { log.WithFields(log.Fields{ - "provider": provider, + "provider": providerFormat, "model": modelInfo.ID, }).Warn("thinking: ValidateConfig returned nil config without error, passthrough |") return body, nil } log.WithFields(log.Fields{ - "provider": provider, + "provider": providerFormat, "model": modelInfo.ID, "mode": validated.Mode, "budget": validated.Budget, @@ -228,7 +236,7 @@ func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig { // applyUserDefinedModel applies thinking configuration for user-defined models // 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 modelID := "" if modelInfo != nil { @@ -240,39 +248,57 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, provider // Get config: suffix priority over body var config ThinkingConfig if suffixResult.HasSuffix { - config = parseSuffixToConfig(suffixResult.RawSuffix, provider, modelID) + config = parseSuffixToConfig(suffixResult.RawSuffix, toFormat, modelID) } else { - config = extractThinkingConfig(body, provider) + config = extractThinkingConfig(body, toFormat) } if !hasThinkingConfig(config) { log.WithFields(log.Fields{ "model": modelID, - "provider": provider, + "provider": toFormat, }).Debug("thinking: user-defined model, passthrough (no config) |") return body, nil } - applier := GetProviderApplier(provider) + applier := GetProviderApplier(toFormat) if applier == nil { log.WithFields(log.Fields{ "model": modelID, - "provider": provider, + "provider": toFormat, }).Debug("thinking: user-defined model, passthrough (unknown provider) |") return body, nil } log.WithFields(log.Fields{ - "provider": provider, + "provider": toFormat, "model": modelID, "mode": config.Mode, "budget": config.Budget, "level": config.Level, }).Debug("thinking: applying config for user-defined model (skip validation)") + config = normalizeUserDefinedConfig(config, fromFormat, toFormat) 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. func extractThinkingConfig(body []byte, provider string) ThinkingConfig { if len(body) == 0 || !gjson.ValidBytes(body) { @@ -289,7 +315,11 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig { case "codex": return extractCodexConfig(body) case "iflow": - return extractIFlowConfig(body) + config := extractIFlowConfig(body) + if hasThinkingConfig(config) { + return config + } + return extractOpenAIConfig(body) default: return ThinkingConfig{} } diff --git a/internal/thinking/errors.go b/internal/thinking/errors.go index 1cf9ccd0..5eed9381 100644 --- a/internal/thinking/errors.go +++ b/internal/thinking/errors.go @@ -24,6 +24,10 @@ const ( // 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" diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index b7833072..3c74d514 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -80,9 +80,66 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo * result, _ := sjson.SetBytes(body, "thinking.type", "enabled") result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget) + + // Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint) + result = a.normalizeClaudeBudget(result, config.Budget, modelInfo) return result, 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 { return body, nil diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index 4904d4d5..eb691715 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -27,28 +27,32 @@ func StripThinkingConfig(body []byte, provider string) []byte { return body } + var paths []string switch provider { case "claude": - result, _ := sjson.DeleteBytes(body, "thinking") - return result + paths = []string{"thinking"} case "gemini": - result, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig") - return result + paths = []string{"generationConfig.thinkingConfig"} case "gemini-cli", "antigravity": - result, _ := sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig") - return result + paths = []string{"request.generationConfig.thinkingConfig"} case "openai": - result, _ := sjson.DeleteBytes(body, "reasoning_effort") - return result + paths = []string{"reasoning_effort"} case "codex": - result, _ := sjson.DeleteBytes(body, "reasoning.effort") - return result + paths = []string{"reasoning.effort"} case "iflow": - result, _ := sjson.DeleteBytes(body, "chat_template_kwargs.enable_thinking") - result, _ = sjson.DeleteBytes(result, "chat_template_kwargs.clear_thinking") - result, _ = sjson.DeleteBytes(result, "reasoning_split") - return result + paths = []string{ + "chat_template_kwargs.enable_thinking", + "chat_template_kwargs.clear_thinking", + "reasoning_split", + "reasoning_effort", + } default: return body } + + result := body + for _, path := range paths { + result, _ = sjson.DeleteBytes(result, path) + } + return result } diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index aabe04eb..f082ad56 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -9,64 +9,6 @@ import ( 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. // // This function performs comprehensive validation: @@ -74,10 +16,16 @@ func ClampBudget(value int, modelInfo *registry.ModelInfo, provider string) int // - 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 @@ -87,9 +35,8 @@ func ClampBudget(value int, modelInfo *registry.ModelInfo, provider string) int // - 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, provider string) (*ThinkingConfig, error) { - normalized := config - +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 { @@ -103,101 +50,108 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provid if config.Mode != ModeNone { return nil, NewThinkingErrorWithModel(ErrThinkingNotSupported, "thinking not supported for this model", model) } - return &normalized, nil + return &config, nil } + allowClampUnsupported := isBudgetBasedProvider(fromFormat) && isLevelBasedProvider(toFormat) + strictBudget := !fromSuffix && fromFormat != "" && isSameProviderFamily(fromFormat, toFormat) + budgetDerivedFromLevel := false + capability := detectModelCapability(modelInfo) switch capability { case CapabilityBudgetOnly: - if normalized.Mode == ModeLevel { - if normalized.Level == LevelAuto { + if config.Mode == ModeLevel { + if config.Level == LevelAuto { break } - budget, ok := ConvertLevelToBudget(string(normalized.Level)) + budget, ok := ConvertLevelToBudget(string(config.Level)) 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 - normalized.Budget = budget - normalized.Level = "" + config.Mode = ModeBudget + config.Budget = budget + config.Level = "" + budgetDerivedFromLevel = true } case CapabilityLevelOnly: - if normalized.Mode == ModeBudget { - level, ok := ConvertBudgetToLevel(normalized.Budget) + 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", normalized.Budget)) + return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("budget %d cannot be converted to a valid level", config.Budget)) } - normalized.Mode = ModeLevel - normalized.Level = ThinkingLevel(level) - normalized.Budget = 0 + // 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 normalized.Mode == ModeLevel && normalized.Level == LevelNone { - normalized.Mode = ModeNone - normalized.Budget = 0 - normalized.Level = "" + if config.Mode == ModeLevel && config.Level == LevelNone { + config.Mode = ModeNone + config.Budget = 0 + config.Level = "" } - if normalized.Mode == ModeLevel && normalized.Level == LevelAuto { - normalized.Mode = ModeAuto - normalized.Budget = -1 - normalized.Level = "" + if config.Mode == ModeLevel && config.Level == LevelAuto { + config.Mode = ModeAuto + config.Budget = -1 + config.Level = "" } - if normalized.Mode == ModeBudget && normalized.Budget == 0 { - normalized.Mode = ModeNone - normalized.Level = "" + if config.Mode == ModeBudget && config.Budget == 0 { + config.Mode = ModeNone + config.Level = "" } - if len(support.Levels) > 0 && normalized.Mode == ModeLevel { - if !isLevelSupported(string(normalized.Level), support.Levels) { - validLevels := normalizeLevels(support.Levels) - message := fmt.Sprintf("level %q not supported, valid levels: %s", strings.ToLower(string(normalized.Level)), strings.Join(validLevels, ", ")) - return nil, NewThinkingError(ErrLevelNotSupported, message) + 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 normalized.Mode == ModeAuto && !support.DynamicAllowed { - normalized = convertAutoToMidRange(normalized, support, provider, model) + if config.Mode == ModeAuto && !support.DynamicAllowed { + 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". // Keep Budget=0 so applier can omit budget_tokens. - normalized.Budget = 0 - normalized.Level = "" + config.Budget = 0 + config.Level = "" } else { - switch normalized.Mode { + switch config.Mode { 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 // This ensures Apply layer doesn't need to access support.Levels - if normalized.Mode == ModeNone && normalized.Budget > 0 && len(support.Levels) > 0 { - normalized.Level = ThinkingLevel(support.Levels[0]) + if config.Mode == ModeNone && config.Budget > 0 && len(support.Levels) > 0 { + config.Level = ThinkingLevel(support.Levels[0]) } } - return &normalized, 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 + return &config, nil } // convertAutoToMidRange converts ModeAuto to a mid-range value when dynamic is not allowed. @@ -246,7 +200,172 @@ func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupp 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_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 +} + +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) { log.WithFields(log.Fields{ "provider": provider, diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 2611b5c6..771a7b4f 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -12,7 +12,6 @@ import ( "strings" "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/translator/gemini/common" "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 if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { - modelInfo := registry.LookupModelInfo(modelName) - if modelInfo != nil && modelInfo.Thinking != nil { - if t.Get("type").String() == "enabled" { - if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { - budget := int(b.Int()) - out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget) - out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - } + if t.Get("type").String() == "enabled" { + if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { + budget := int(b.Int()) + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget) + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 1d727c94..1babf65c 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -343,8 +343,8 @@ func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) { if thinkingConfig.Get("thinkingBudget").Int() != 8000 { t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int()) } - if !thinkingConfig.Get("include_thoughts").Bool() { - t.Error("include_thoughts should be true") + if !thinkingConfig.Get("includeThoughts").Bool() { + t.Error("includeThoughts should be true") } } else { t.Log("thinkingConfig not present - model may not be registered in test registry") diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index d52b1a53..94546bda 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -132,6 +132,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } + systemPartIndex := 0 for i := 0; i < len(arr); i++ { m := arr[i] role := m.Get("role").String() @@ -141,16 +142,19 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ // system -> request.systemInstruction as a user message style if content.Type == gjson.String { out, _ = sjson.SetBytes(out, "request.systemInstruction.role", "user") - out, _ = sjson.SetBytes(out, "request.systemInstruction.parts.0.text", content.String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", systemPartIndex), content.String()) + systemPartIndex++ } else if content.IsObject() && content.Get("type").String() == "text" { out, _ = sjson.SetBytes(out, "request.systemInstruction.role", "user") - out, _ = sjson.SetBytes(out, "request.systemInstruction.parts.0.text", content.Get("text").String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", systemPartIndex), content.Get("text").String()) + systemPartIndex++ } else if content.IsArray() { contents := content.Array() if len(contents) > 0 { out, _ = sjson.SetBytes(out, "request.systemInstruction.role", "user") for j := 0; j < len(contents); j++ { - out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", j), contents[j].Get("text").String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", systemPartIndex), contents[j].Get("text").String()) + systemPartIndex++ } } } @@ -165,7 +169,10 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ for _, item := range items { switch item.Get("type").String() { case "text": - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", item.Get("text").String()) + text := item.Get("text").String() + if text != "" { + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", text) + } p++ case "image_url": imageURL := item.Get("image_url.url").String() @@ -209,7 +216,10 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ for _, item := range content.Array() { switch item.Get("type").String() { case "text": - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", item.Get("text").String()) + text := item.Get("text").String() + if text != "" { + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", text) + } p++ case "image_url": // If the assistant returned an inline data URL, preserve it for history fidelity. diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 89857693..32f2d847 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -15,7 +15,7 @@ import ( "strings" "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/tidwall/gjson" "github.com/tidwall/sjson" @@ -115,18 +115,41 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream } } // 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() { - modelInfo := registry.LookupModelInfo(modelName) - if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 { - // Check for thinkingBudget first - if present, enable thinking with budget - if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() && thinkingBudget.Int() > 0 { - out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Set(out, "thinking.budget_tokens", thinkingBudget.Int()) - } else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True { - // Fallback to include_thoughts if no budget specified + if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() { + level := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) + switch level { + case "": + case "none": + out, _ = sjson.Set(out, "thinking.type", "disabled") + out, _ = sjson.Delete(out, "thinking.budget_tokens") + case "auto": 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") } } } diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 3a165a3d..79dc9c90 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -15,7 +15,6 @@ import ( "strings" "github.com/google/uuid" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -66,23 +65,21 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream root := gjson.ParseBytes(rawJSON) + // Convert OpenAI reasoning_effort to Claude thinking config. if v := root.Get("reasoning_effort"); v.Exists() { - modelInfo := registry.LookupModelInfo(modelName) - if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 { - effort := strings.ToLower(strings.TrimSpace(v.String())) - if effort != "" { - budget, ok := thinking.ConvertLevelToBudget(effort) - if ok { - switch budget { - case 0: - out, _ = sjson.Set(out, "thinking.type", "disabled") - case -1: + effort := strings.ToLower(strings.TrimSpace(v.String())) + if effort != "" { + budget, ok := thinking.ConvertLevelToBudget(effort) + if ok { + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + case -1: + out, _ = sjson.Set(out, "thinking.type", "enabled") + default: + if budget > 0 { out, _ = sjson.Set(out, "thinking.type", "enabled") - default: - if budget > 0 { - out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Set(out, "thinking.budget_tokens", budget) - } + out, _ = sjson.Set(out, "thinking.budget_tokens", budget) } } } @@ -141,17 +138,35 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream // Process messages and transform them to Claude Code format if messages := root.Get("messages"); messages.Exists() && messages.IsArray() { + messageIndex := 0 + systemMessageIndex := -1 messages.ForEach(func(_, message gjson.Result) bool { role := message.Get("role").String() contentResult := message.Get("content") switch role { - case "system", "user", "assistant": - // Create Claude Code message with appropriate role mapping - if role == "system" { - role = "user" + case "system": + if systemMessageIndex == -1 { + systemMsg := `{"role":"user","content":[]}` + out, _ = sjson.SetRaw(out, "messages.-1", systemMsg) + systemMessageIndex = messageIndex + messageIndex++ } - + if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" { + textPart := `{"type":"text","text":""}` + textPart, _ = sjson.Set(textPart, "text", contentResult.String()) + out, _ = sjson.SetRaw(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart) + } else if contentResult.Exists() && contentResult.IsArray() { + contentResult.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "text" { + textPart := `{"type":"text","text":""}` + textPart, _ = sjson.Set(textPart, "text", part.Get("text").String()) + out, _ = sjson.SetRaw(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart) + } + return true + }) + } + case "user", "assistant": msg := `{"role":"","content":[]}` msg, _ = sjson.Set(msg, "role", role) @@ -230,6 +245,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream } out, _ = sjson.SetRaw(out, "messages.-1", msg) + messageIndex++ case "tool": // Handle tool result messages conversion @@ -240,6 +256,7 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream msg, _ = sjson.Set(msg, "content.0.tool_use_id", toolCallID) msg, _ = sjson.Set(msg, "content.0.content", content) out, _ = sjson.SetRaw(out, "messages.-1", msg) + messageIndex++ } return true }) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 479fb78f..5cbe23bf 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/google/uuid" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -54,23 +53,21 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte root := gjson.ParseBytes(rawJSON) + // Convert OpenAI Responses reasoning.effort to Claude thinking config. if v := root.Get("reasoning.effort"); v.Exists() { - modelInfo := registry.LookupModelInfo(modelName) - if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 { - effort := strings.ToLower(strings.TrimSpace(v.String())) - if effort != "" { - budget, ok := thinking.ConvertLevelToBudget(effort) - if ok { - switch budget { - case 0: - out, _ = sjson.Set(out, "thinking.type", "disabled") - case -1: + effort := strings.ToLower(strings.TrimSpace(v.String())) + if effort != "" { + budget, ok := thinking.ConvertLevelToBudget(effort) + if ok { + switch budget { + case 0: + out, _ = sjson.Set(out, "thinking.type", "disabled") + case -1: + out, _ = sjson.Set(out, "thinking.type", "enabled") + default: + if budget > 0 { out, _ = sjson.Set(out, "thinking.type", "enabled") - default: - if budget > 0 { - out, _ = sjson.Set(out, "thinking.type", "enabled") - out, _ = sjson.Set(out, "thinking.budget_tokens", budget) - } + out, _ = sjson.Set(out, "thinking.budget_tokens", budget) } } } diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 17f2f674..f0f5d867 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -12,7 +12,6 @@ import ( "strings" "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/tidwall/gjson" "github.com/tidwall/sjson" @@ -218,18 +217,15 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Add additional configuration parameters for the Codex API. template, _ = sjson.Set(template, "parallel_tool_calls", true) - // Convert thinking.budget_tokens to reasoning.effort for level-based models - reasoningEffort := "medium" // default + // Convert thinking.budget_tokens to reasoning.effort. + reasoningEffort := "medium" if thinkingConfig := rootResult.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() { - modelInfo := registry.LookupModelInfo(modelName) switch thinkingConfig.Get("type").String() { case "enabled": - if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 { - if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() { - budget := int(budgetTokens.Int()) - if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" { - reasoningEffort = effort - } + if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() { + budget := int(budgetTokens.Int()) + if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" { + reasoningEffort = effort } } case "disabled": diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index c700ef84..5223cd94 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -117,8 +117,12 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa } else { template, _ = sjson.Set(template, "delta.stop_reason", "end_turn") } - template, _ = sjson.Set(template, "usage.input_tokens", rootResult.Get("response.usage.input_tokens").Int()) - template, _ = sjson.Set(template, "usage.output_tokens", rootResult.Get("response.usage.output_tokens").Int()) + inputTokens, outputTokens, cachedTokens := extractResponsesUsage(rootResult.Get("response.usage")) + template, _ = sjson.Set(template, "usage.input_tokens", inputTokens) + template, _ = sjson.Set(template, "usage.output_tokens", outputTokens) + if cachedTokens > 0 { + template, _ = sjson.Set(template, "usage.cache_read_input_tokens", cachedTokens) + } output = "event: message_delta\n" output += fmt.Sprintf("data: %s\n\n", template) @@ -204,8 +208,12 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original out := `{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}` out, _ = sjson.Set(out, "id", responseData.Get("id").String()) out, _ = sjson.Set(out, "model", responseData.Get("model").String()) - out, _ = sjson.Set(out, "usage.input_tokens", responseData.Get("usage.input_tokens").Int()) - out, _ = sjson.Set(out, "usage.output_tokens", responseData.Get("usage.output_tokens").Int()) + inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage")) + out, _ = sjson.Set(out, "usage.input_tokens", inputTokens) + out, _ = sjson.Set(out, "usage.output_tokens", outputTokens) + if cachedTokens > 0 { + out, _ = sjson.Set(out, "usage.cache_read_input_tokens", cachedTokens) + } hasToolCall := false @@ -308,12 +316,27 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original out, _ = sjson.SetRaw(out, "stop_sequence", stopSequence.Raw) } - if responseData.Get("usage.input_tokens").Exists() || responseData.Get("usage.output_tokens").Exists() { - out, _ = sjson.Set(out, "usage.input_tokens", responseData.Get("usage.input_tokens").Int()) - out, _ = sjson.Set(out, "usage.output_tokens", responseData.Get("usage.output_tokens").Int()) + return out +} + +func extractResponsesUsage(usage gjson.Result) (int64, int64, int64) { + if !usage.Exists() || usage.Type == gjson.Null { + return 0, 0, 0 } - return out + inputTokens := usage.Get("input_tokens").Int() + outputTokens := usage.Get("output_tokens").Int() + cachedTokens := usage.Get("input_tokens_details.cached_tokens").Int() + + if cachedTokens > 0 { + if inputTokens >= cachedTokens { + inputTokens -= cachedTokens + } else { + inputTokens = 0 + } + } + + return inputTokens, outputTokens, cachedTokens } // buildReverseMapFromClaudeOriginalShortToOriginal builds a map[short]original from original Claude request tools. diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index d7d0a109..342c5b1a 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -14,7 +14,6 @@ import ( "strings" "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/util" "github.com/tidwall/gjson" @@ -249,22 +248,28 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Fixed flags aligning with Codex expectations out, _ = sjson.Set(out, "parallel_tool_calls", true) - // Convert thinkingBudget to reasoning.effort for level-based models - reasoningEffort := "medium" // default + // Convert Gemini thinkingConfig to Codex reasoning.effort. + effortSet := false if genConfig := root.Get("generationConfig"); genConfig.Exists() { if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { - modelInfo := registry.LookupModelInfo(modelName) - if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 { - if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { - budget := int(thinkingBudget.Int()) - if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" { - reasoningEffort = effort - } + if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() { + effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) + if effort != "" { + out, _ = sjson.Set(out, "reasoning.effort", effort) + effortSet = true + } + } 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, "stream", true) out, _ = sjson.Set(out, "store", false) diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index f1bed88b..f4a51e8b 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -9,7 +9,6 @@ import ( "bytes" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/tidwall/gjson" "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 if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { - modelInfo := registry.LookupModelInfo(modelName) - if modelInfo != nil && modelInfo.Thinking != nil { - if t.Get("type").String() == "enabled" { - if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { - budget := int(b.Int()) - out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget) - out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.include_thoughts", true) - } + if t.Get("type").String() == "enabled" { + if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { + budget := int(b.Int()) + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget) + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } } } diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index 938a5ae4..af161b5c 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -129,6 +129,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo } } + systemPartIndex := 0 for i := 0; i < len(arr); i++ { m := arr[i] role := m.Get("role").String() @@ -138,16 +139,19 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo // system -> request.systemInstruction as a user message style if content.Type == gjson.String { out, _ = sjson.SetBytes(out, "request.systemInstruction.role", "user") - out, _ = sjson.SetBytes(out, "request.systemInstruction.parts.0.text", content.String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", systemPartIndex), content.String()) + systemPartIndex++ } else if content.IsObject() && content.Get("type").String() == "text" { out, _ = sjson.SetBytes(out, "request.systemInstruction.role", "user") - out, _ = sjson.SetBytes(out, "request.systemInstruction.parts.0.text", content.Get("text").String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", systemPartIndex), content.Get("text").String()) + systemPartIndex++ } else if content.IsArray() { contents := content.Array() if len(contents) > 0 { out, _ = sjson.SetBytes(out, "request.systemInstruction.role", "user") for j := 0; j < len(contents); j++ { - out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", j), contents[j].Get("text").String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", systemPartIndex), contents[j].Get("text").String()) + systemPartIndex++ } } } diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index f0dbd513..0d5361a5 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -9,7 +9,6 @@ import ( "bytes" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -153,16 +152,13 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } // 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() { - modelInfo := registry.LookupModelInfo(modelName) - if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) == 0 { - if t.Get("type").String() == "enabled" { - if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { - budget := int(b.Int()) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget) - out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true) - } + if t.Get("type").String() == "enabled" { + if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { + budget := int(b.Int()) + out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget) + out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true) } } } diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index fedd8dca..7ad005b9 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -129,6 +129,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } } + systemPartIndex := 0 for i := 0; i < len(arr); i++ { m := arr[i] role := m.Get("role").String() @@ -138,16 +139,19 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) // system -> system_instruction as a user message style if content.Type == gjson.String { out, _ = sjson.SetBytes(out, "system_instruction.role", "user") - out, _ = sjson.SetBytes(out, "system_instruction.parts.0.text", content.String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("system_instruction.parts.%d.text", systemPartIndex), content.String()) + systemPartIndex++ } else if content.IsObject() && content.Get("type").String() == "text" { out, _ = sjson.SetBytes(out, "system_instruction.role", "user") - out, _ = sjson.SetBytes(out, "system_instruction.parts.0.text", content.Get("text").String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("system_instruction.parts.%d.text", systemPartIndex), content.Get("text").String()) + systemPartIndex++ } else if content.IsArray() { contents := content.Array() if len(contents) > 0 { - out, _ = sjson.SetBytes(out, "request.systemInstruction.role", "user") + out, _ = sjson.SetBytes(out, "system_instruction.role", "user") for j := 0; j < len(contents); j++ { - out, _ = sjson.SetBytes(out, fmt.Sprintf("request.systemInstruction.parts.%d.text", j), contents[j].Get("text").String()) + out, _ = sjson.SetBytes(out, fmt.Sprintf("system_instruction.parts.%d.text", systemPartIndex), contents[j].Get("text").String()) + systemPartIndex++ } } } @@ -162,7 +166,10 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) for _, item := range items { switch item.Get("type").String() { case "text": - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", item.Get("text").String()) + text := item.Get("text").String() + if text != "" { + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", text) + } p++ case "image_url": imageURL := item.Get("image_url.url").String() @@ -207,6 +214,10 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) for _, item := range content.Array() { switch item.Get("type").String() { case "text": + text := item.Get("text").String() + if text != "" { + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", text) + } p++ case "image_url": // If the assistant returned an inline data URL, preserve it for history fidelity. diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 3817b77b..c268ec62 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -88,7 +88,7 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream var messagesJSON = "[]" // Handle system message first - systemMsgJSON := `{"role":"system","content":[{"type":"text","text":"Use ANY tool, the parameters MUST accord with RFC 8259 (The JavaScript Object Notation (JSON) Data Interchange Format), the keys and value MUST be enclosed in double quotes."}]}` + systemMsgJSON := `{"role":"system","content":[]}` if system := root.Get("system"); system.Exists() { if system.Type == gjson.String { if system.String() != "" { diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 1629545d..b6e0d005 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -289,21 +289,17 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI // Only process if usage has actual values (not null) if param.FinishReason != "" { usage := root.Get("usage") - var inputTokens, outputTokens int64 + var inputTokens, outputTokens, cachedTokens int64 if usage.Exists() && usage.Type != gjson.Null { - // Check if usage has actual token counts - promptTokens := usage.Get("prompt_tokens") - completionTokens := usage.Get("completion_tokens") - - if promptTokens.Exists() && completionTokens.Exists() { - inputTokens = promptTokens.Int() - outputTokens = completionTokens.Int() - } + inputTokens, outputTokens, cachedTokens = extractOpenAIUsage(usage) // Send message_delta with usage messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "delta.stop_reason", mapOpenAIFinishReasonToAnthropic(param.FinishReason)) messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "usage.input_tokens", inputTokens) messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "usage.output_tokens", outputTokens) + if cachedTokens > 0 { + messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "usage.cache_read_input_tokens", cachedTokens) + } results = append(results, "event: message_delta\ndata: "+messageDeltaJSON+"\n\n") param.MessageDeltaSent = true @@ -423,13 +419,12 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) []string { // Set usage information if usage := root.Get("usage"); usage.Exists() { - out, _ = sjson.Set(out, "usage.input_tokens", usage.Get("prompt_tokens").Int()) - out, _ = sjson.Set(out, "usage.output_tokens", usage.Get("completion_tokens").Int()) - reasoningTokens := int64(0) - if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() { - reasoningTokens = v.Int() + inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(usage) + out, _ = sjson.Set(out, "usage.input_tokens", inputTokens) + out, _ = sjson.Set(out, "usage.output_tokens", outputTokens) + if cachedTokens > 0 { + out, _ = sjson.Set(out, "usage.cache_read_input_tokens", cachedTokens) } - out, _ = sjson.Set(out, "usage.reasoning_tokens", reasoningTokens) } return []string{out} @@ -674,8 +669,12 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina } if respUsage := root.Get("usage"); respUsage.Exists() { - out, _ = sjson.Set(out, "usage.input_tokens", respUsage.Get("prompt_tokens").Int()) - out, _ = sjson.Set(out, "usage.output_tokens", respUsage.Get("completion_tokens").Int()) + inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(respUsage) + out, _ = sjson.Set(out, "usage.input_tokens", inputTokens) + out, _ = sjson.Set(out, "usage.output_tokens", outputTokens) + if cachedTokens > 0 { + out, _ = sjson.Set(out, "usage.cache_read_input_tokens", cachedTokens) + } } if !stopReasonSet { @@ -692,3 +691,23 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina func ClaudeTokenCount(ctx context.Context, count int64) string { return fmt.Sprintf(`{"input_tokens":%d}`, count) } + +func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) { + if !usage.Exists() || usage.Type == gjson.Null { + return 0, 0, 0 + } + + inputTokens := usage.Get("prompt_tokens").Int() + outputTokens := usage.Get("completion_tokens").Int() + cachedTokens := usage.Get("prompt_tokens_details.cached_tokens").Int() + + if cachedTokens > 0 { + if inputTokens >= cachedTokens { + inputTokens -= cachedTokens + } else { + inputTokens = 0 + } + } + + return inputTokens, outputTokens, cachedTokens +} diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go index 7cdcb0f8..6e9bf637 100644 --- a/internal/translator/openai/gemini/openai_gemini_request.go +++ b/internal/translator/openai/gemini/openai_gemini_request.go @@ -77,12 +77,15 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } } - // Convert thinkingBudget to reasoning_effort - // Always perform conversion to support allowCompat models that may not be in registry + // Map Gemini thinkingConfig to OpenAI reasoning_effort. if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { - if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { - budget := int(thinkingBudget.Int()) - if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" { + if thinkingLevel := thinkingConfig.Get("thinkingLevel"); thinkingLevel.Exists() { + effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) + 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) } } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 17233ca5..15152852 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -12,6 +12,10 @@ import ( "github.com/tidwall/sjson" ) +type oaiToResponsesStateReasoning struct { + ReasoningID string + ReasoningData string +} type oaiToResponsesState struct { Seq int ResponseID string @@ -23,6 +27,7 @@ type oaiToResponsesState struct { // Per-output message text buffers by index MsgTextBuf map[int]*strings.Builder ReasoningBuf strings.Builder + Reasonings []oaiToResponsesStateReasoning FuncArgsBuf map[int]*strings.Builder // index -> args FuncNames map[int]string // index -> name FuncCallIDs map[int]string // index -> call_id @@ -63,6 +68,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, MsgItemDone: make(map[int]bool), FuncArgsDone: make(map[int]bool), FuncItemDone: make(map[int]bool), + Reasonings: make([]oaiToResponsesStateReasoning, 0), } } st := (*param).(*oaiToResponsesState) @@ -157,6 +163,31 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.Started = true } + stopReasoning := func(text string) { + // Emit reasoning done events + textDone := `{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}` + textDone, _ = sjson.Set(textDone, "sequence_number", nextSeq()) + textDone, _ = sjson.Set(textDone, "item_id", st.ReasoningID) + textDone, _ = sjson.Set(textDone, "output_index", st.ReasoningIndex) + textDone, _ = sjson.Set(textDone, "text", text) + out = append(out, emitRespEvent("response.reasoning_summary_text.done", textDone)) + partDone := `{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}` + partDone, _ = sjson.Set(partDone, "sequence_number", nextSeq()) + partDone, _ = sjson.Set(partDone, "item_id", st.ReasoningID) + partDone, _ = sjson.Set(partDone, "output_index", st.ReasoningIndex) + partDone, _ = sjson.Set(partDone, "part.text", text) + out = append(out, emitRespEvent("response.reasoning_summary_part.done", partDone)) + outputItemDone := `{"type":"response.output_item.done","item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]},"output_index":0,"sequence_number":0}` + outputItemDone, _ = sjson.Set(outputItemDone, "sequence_number", nextSeq()) + outputItemDone, _ = sjson.Set(outputItemDone, "item.id", st.ReasoningID) + outputItemDone, _ = sjson.Set(outputItemDone, "output_index", st.ReasoningIndex) + outputItemDone, _ = sjson.Set(outputItemDone, "item.summary.text", text) + out = append(out, emitRespEvent("response.output_item.done", outputItemDone)) + + st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text}) + st.ReasoningID = "" + } + // choices[].delta content / tool_calls / reasoning_content if choices := root.Get("choices"); choices.Exists() && choices.IsArray() { choices.ForEach(func(_, choice gjson.Result) bool { @@ -165,6 +196,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, if delta.Exists() { if c := delta.Get("content"); c.Exists() && c.String() != "" { // Ensure the message item and its first content part are announced before any text deltas + if st.ReasoningID != "" { + stopReasoning(st.ReasoningBuf.String()) + st.ReasoningBuf.Reset() + } if !st.MsgItemAdded[idx] { item := `{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}` item, _ = sjson.Set(item, "sequence_number", nextSeq()) @@ -226,6 +261,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, // tool calls if tcs := delta.Get("tool_calls"); tcs.Exists() && tcs.IsArray() { + if st.ReasoningID != "" { + stopReasoning(st.ReasoningBuf.String()) + st.ReasoningBuf.Reset() + } // Before emitting any function events, if a message is open for this index, // close its text/content to match Codex expected ordering. if st.MsgItemAdded[idx] && !st.MsgItemDone[idx] { @@ -361,17 +400,8 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } if st.ReasoningID != "" { - // Emit reasoning done events - textDone := `{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}` - textDone, _ = sjson.Set(textDone, "sequence_number", nextSeq()) - textDone, _ = sjson.Set(textDone, "item_id", st.ReasoningID) - textDone, _ = sjson.Set(textDone, "output_index", st.ReasoningIndex) - out = append(out, emitRespEvent("response.reasoning_summary_text.done", textDone)) - partDone := `{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}` - partDone, _ = sjson.Set(partDone, "sequence_number", nextSeq()) - partDone, _ = sjson.Set(partDone, "item_id", st.ReasoningID) - partDone, _ = sjson.Set(partDone, "output_index", st.ReasoningIndex) - out = append(out, emitRespEvent("response.reasoning_summary_part.done", partDone)) + stopReasoning(st.ReasoningBuf.String()) + st.ReasoningBuf.Reset() } // Emit function call done events for any active function calls @@ -485,11 +515,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } // Build response.output using aggregated buffers outputsWrapper := `{"arr":[]}` - if st.ReasoningBuf.Len() > 0 { - item := `{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}` - item, _ = sjson.Set(item, "id", st.ReasoningID) - item, _ = sjson.Set(item, "summary.0.text", st.ReasoningBuf.String()) - outputsWrapper, _ = sjson.SetRaw(outputsWrapper, "arr.-1", item) + if len(st.Reasonings) > 0 { + for _, r := range st.Reasonings { + item := `{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}` + item, _ = sjson.Set(item, "id", r.ReasoningID) + item, _ = sjson.Set(item, "summary.0.text", r.ReasoningData) + outputsWrapper, _ = sjson.SetRaw(outputsWrapper, "arr.-1", item) + } } // Append message items in ascending index order if len(st.MsgItemAdded) > 0 { diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 38d3773e..c7cb0f40 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -19,6 +19,7 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string { // Phase 1: Convert and add hints jsonStr = convertRefsToHints(jsonStr) jsonStr = convertConstToEnum(jsonStr) + jsonStr = convertEnumValuesToStrings(jsonStr) jsonStr = addEnumHints(jsonStr) jsonStr = addAdditionalPropertiesHints(jsonStr) jsonStr = moveConstraintsToDescription(jsonStr) @@ -77,6 +78,33 @@ func convertConstToEnum(jsonStr string) string { return jsonStr } +// convertEnumValuesToStrings ensures all enum values are strings. +// Gemini API requires enum values to be of type string, not numbers or booleans. +func convertEnumValuesToStrings(jsonStr string) string { + for _, p := range findPaths(jsonStr, "enum") { + arr := gjson.Get(jsonStr, p) + if !arr.IsArray() { + continue + } + + var stringVals []string + needsConversion := false + for _, item := range arr.Array() { + // Check if any value is not a string + if item.Type != gjson.String { + needsConversion = true + } + stringVals = append(stringVals, item.String()) + } + + // Only update if we found non-string values + if needsConversion { + jsonStr, _ = sjson.Set(jsonStr, p, stringVals) + } + } + return jsonStr +} + func addEnumHints(jsonStr string) string { for _, p := range findPaths(jsonStr, "enum") { arr := gjson.Get(jsonStr, p) diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 60335f22..ca77225e 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -818,3 +818,54 @@ func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) { t.Errorf("date-time format hint should be added, got: %s", result) } } + +func TestCleanJSONSchemaForAntigravity_NumericEnumToString(t *testing.T) { + // Gemini API requires enum values to be strings, not numbers + input := `{ + "type": "object", + "properties": { + "priority": {"type": "integer", "enum": [0, 1, 2]}, + "level": {"type": "number", "enum": [1.5, 2.5, 3.5]}, + "status": {"type": "string", "enum": ["active", "inactive"]} + } + }` + + result := CleanJSONSchemaForAntigravity(input) + + // Numeric enum values should be converted to strings + if strings.Contains(result, `"enum":[0,1,2]`) { + t.Errorf("Integer enum values should be converted to strings, got: %s", result) + } + if strings.Contains(result, `"enum":[1.5,2.5,3.5]`) { + t.Errorf("Float enum values should be converted to strings, got: %s", result) + } + // Should contain string versions + if !strings.Contains(result, `"0"`) || !strings.Contains(result, `"1"`) || !strings.Contains(result, `"2"`) { + t.Errorf("Integer enum values should be converted to string format, got: %s", result) + } + // String enum values should remain unchanged + if !strings.Contains(result, `"active"`) || !strings.Contains(result, `"inactive"`) { + t.Errorf("String enum values should remain unchanged, got: %s", result) + } +} + +func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) { + // Boolean enum values should also be converted to strings + input := `{ + "type": "object", + "properties": { + "enabled": {"type": "boolean", "enum": [true, false]} + } + }` + + result := CleanJSONSchemaForAntigravity(input) + + // Boolean enum values should be converted to strings + if strings.Contains(result, `"enum":[true,false]`) { + t.Errorf("Boolean enum values should be converted to strings, got: %s", result) + } + // Should contain string versions "true" and "false" + if !strings.Contains(result, `"true"`) || !strings.Contains(result, `"false"`) { + t.Errorf("Boolean enum values should be converted to string format, got: %s", result) + } +} diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 4c69ae90..44825951 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -227,6 +227,18 @@ func (a *Auth) AccountInfo() (string, string) { } } + // For GitHub provider, return username + if strings.ToLower(a.Provider) == "github" { + if a.Metadata != nil { + if username, ok := a.Metadata["username"].(string); ok { + username = strings.TrimSpace(username) + if username != "" { + return "oauth", username + } + } + } + } + // Check metadata for email first (OAuth-style auth) if a.Metadata != nil { if v, ok := a.Metadata["email"].(string); ok { diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index f28aa630..4a7df29a 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -20,17 +20,2577 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) -// TestThinkingE2EMatrix tests the thinking configuration transformation using the real data flow path. +// thinkingTestCase represents a common test case structure for both suffix and body tests. +type thinkingTestCase struct { + name string + from string + to string + model string + inputJSON string + expectField string + expectValue string + includeThoughts string + expectErr bool +} + +// TestThinkingE2EMatrix_Suffix tests the thinking configuration transformation using model name suffix. // Data flow: Input JSON → TranslateRequest → ApplyThinking → Validate Output // No helper functions are used; all test data is inline. -func TestThinkingE2EMatrix(t *testing.T) { - // Register test models directly +func TestThinkingE2EMatrix_Suffix(t *testing.T) { reg := registry.GetGlobalRegistry() - uid := fmt.Sprintf("thinking-e2e-%d", time.Now().UnixNano()) + uid := fmt.Sprintf("thinking-e2e-suffix-%d", time.Now().UnixNano()) - testModels := []*registry.ModelInfo{ + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + cases := []thinkingTestCase{ + // level-model (Levels=minimal/low/medium/high, ZeroAllowed=false, DynamicAllowed=false) + + // Case 1: No suffix → injected default → medium + { + name: "1", + from: "openai", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 2: Specified medium → medium + { + name: "2", + from: "openai", + to: "codex", + model: "level-model(medium)", + inputJSON: `{"model":"level-model(medium)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 3: Specified xhigh → out of range error + { + name: "3", + from: "openai", + to: "codex", + model: "level-model(xhigh)", + inputJSON: `{"model":"level-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: true, + }, + // Case 4: Level none → clamped to minimal (ZeroAllowed=false) + { + name: "4", + from: "openai", + to: "codex", + model: "level-model(none)", + inputJSON: `{"model":"level-model(none)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "minimal", + expectErr: false, + }, + // Case 5: Level auto → DynamicAllowed=false → medium (mid-range) + { + name: "5", + from: "openai", + to: "codex", + model: "level-model(auto)", + inputJSON: `{"model":"level-model(auto)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 6: No suffix from gemini → injected default → medium + { + name: "6", + from: "gemini", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 7: Budget 8192 → medium + { + name: "7", + from: "gemini", + to: "codex", + model: "level-model(8192)", + inputJSON: `{"model":"level-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 8: Budget 64000 → clamped to high + { + name: "8", + from: "gemini", + to: "codex", + model: "level-model(64000)", + inputJSON: `{"model":"level-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + // Case 9: Budget 0 → clamped to minimal (ZeroAllowed=false) + { + name: "9", + from: "gemini", + to: "codex", + model: "level-model(0)", + inputJSON: `{"model":"level-model(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning.effort", + expectValue: "minimal", + expectErr: false, + }, + // Case 10: Budget -1 → auto → DynamicAllowed=false → medium (mid-range) + { + name: "10", + from: "gemini", + to: "codex", + model: "level-model(-1)", + inputJSON: `{"model":"level-model(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 11: Claude source no suffix → passthrough (no thinking) + { + name: "11", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 12: Budget 8192 → medium + { + name: "12", + from: "claude", + to: "openai", + model: "level-model(8192)", + inputJSON: `{"model":"level-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning_effort", + expectValue: "medium", + expectErr: false, + }, + // Case 13: Budget 64000 → clamped to high + { + name: "13", + from: "claude", + to: "openai", + model: "level-model(64000)", + inputJSON: `{"model":"level-model(64000)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, + }, + // Case 14: Budget 0 → clamped to minimal (ZeroAllowed=false) + { + name: "14", + from: "claude", + to: "openai", + model: "level-model(0)", + inputJSON: `{"model":"level-model(0)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning_effort", + expectValue: "minimal", + expectErr: false, + }, + // Case 15: Budget -1 → auto → DynamicAllowed=false → medium (mid-range) + { + name: "15", + from: "claude", + to: "openai", + model: "level-model(-1)", + inputJSON: `{"model":"level-model(-1)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning_effort", + expectValue: "medium", + expectErr: false, + }, + + // level-subset-model (Levels=low/high, ZeroAllowed=false, DynamicAllowed=false) + + // Case 16: Budget 8192 → medium → rounded down to low + { + name: "16", + from: "gemini", + to: "openai", + model: "level-subset-model(8192)", + inputJSON: `{"model":"level-subset-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning_effort", + expectValue: "low", + expectErr: false, + }, + // Case 17: Budget 1 → minimal → clamped to low (min supported) + { + name: "17", + from: "claude", + to: "gemini", + model: "level-subset-model(1)", + inputJSON: `{"model":"level-subset-model(1)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "low", + includeThoughts: "true", + expectErr: false, + }, + + // gemini-budget-model (Min=128, Max=20000, ZeroAllowed=false, DynamicAllowed=true) + + // Case 18: No suffix → passthrough + { + name: "18", + from: "openai", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 19: Effort medium → 8192 + { + name: "19", + from: "openai", + to: "gemini", + model: "gemini-budget-model(medium)", + inputJSON: `{"model":"gemini-budget-model(medium)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 20: Effort xhigh → clamped to 20000 (max) + { + name: "20", + from: "openai", + to: "gemini", + model: "gemini-budget-model(xhigh)", + inputJSON: `{"model":"gemini-budget-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 21: Effort none → clamped to 128 (min) → includeThoughts=false + { + name: "21", + from: "openai", + to: "gemini", + model: "gemini-budget-model(none)", + inputJSON: `{"model":"gemini-budget-model(none)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "128", + includeThoughts: "false", + expectErr: false, + }, + // Case 22: Effort auto → DynamicAllowed=true → -1 + { + name: "22", + from: "openai", + to: "gemini", + model: "gemini-budget-model(auto)", + inputJSON: `{"model":"gemini-budget-model(auto)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + // Case 23: Claude source no suffix → passthrough + { + name: "23", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 24: Budget 8192 → 8192 + { + name: "24", + from: "claude", + to: "gemini", + model: "gemini-budget-model(8192)", + inputJSON: `{"model":"gemini-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 25: Budget 64000 → clamped to 20000 (max) + { + name: "25", + from: "claude", + to: "gemini", + model: "gemini-budget-model(64000)", + inputJSON: `{"model":"gemini-budget-model(64000)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 26: Budget 0 → clamped to 128 (min) → includeThoughts=false + { + name: "26", + from: "claude", + to: "gemini", + model: "gemini-budget-model(0)", + inputJSON: `{"model":"gemini-budget-model(0)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "128", + includeThoughts: "false", + expectErr: false, + }, + // Case 27: Budget -1 → DynamicAllowed=true → -1 + { + name: "27", + from: "claude", + to: "gemini", + model: "gemini-budget-model(-1)", + inputJSON: `{"model":"gemini-budget-model(-1)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + + // gemini-mixed-model (Min=128, Max=32768, Levels=low/high, ZeroAllowed=false, DynamicAllowed=true) + + // Case 28: OpenAI source no suffix → passthrough + { + name: "28", + from: "openai", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 29: Effort high → low/high supported → high + { + name: "29", + from: "openai", + to: "gemini", + model: "gemini-mixed-model(high)", + inputJSON: `{"model":"gemini-mixed-model(high)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "high", + includeThoughts: "true", + expectErr: false, + }, + // Case 30: Effort xhigh → not in low/high → error + { + name: "30", + from: "openai", + to: "gemini", + model: "gemini-mixed-model(xhigh)", + inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: true, + }, + // Case 31: Effort none → clamped to low (min supported) → includeThoughts=false + { + name: "31", + from: "openai", + to: "gemini", + model: "gemini-mixed-model(none)", + inputJSON: `{"model":"gemini-mixed-model(none)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "low", + includeThoughts: "false", + expectErr: false, + }, + // Case 32: Effort auto → DynamicAllowed=true → -1 (budget) + { + name: "32", + from: "openai", + to: "gemini", + model: "gemini-mixed-model(auto)", + inputJSON: `{"model":"gemini-mixed-model(auto)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + // Case 33: Claude source no suffix → passthrough + { + name: "33", + from: "claude", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 34: Budget 8192 → 8192 (keep budget) + { + name: "34", + from: "claude", + to: "gemini", + model: "gemini-mixed-model(8192)", + inputJSON: `{"model":"gemini-mixed-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 35: Budget 64000 → clamped to 32768 (max) + { + name: "35", + from: "claude", + to: "gemini", + model: "gemini-mixed-model(64000)", + inputJSON: `{"model":"gemini-mixed-model(64000)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "32768", + includeThoughts: "true", + expectErr: false, + }, + // Case 36: Budget 0 → minimal → clamped to low (min level) → includeThoughts=false + { + name: "36", + from: "claude", + to: "gemini", + model: "gemini-mixed-model(0)", + inputJSON: `{"model":"gemini-mixed-model(0)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "low", + includeThoughts: "false", + expectErr: false, + }, + // Case 37: Budget -1 → DynamicAllowed=true → -1 (budget) + { + name: "37", + from: "claude", + to: "gemini", + model: "gemini-mixed-model(-1)", + inputJSON: `{"model":"gemini-mixed-model(-1)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + + // claude-budget-model (Min=1024, Max=128000, ZeroAllowed=true, DynamicAllowed=false) + + // Case 38: OpenAI source no suffix → passthrough + { + name: "38", + from: "openai", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 39: Effort medium → 8192 + { + name: "39", + from: "openai", + to: "claude", + model: "claude-budget-model(medium)", + inputJSON: `{"model":"claude-budget-model(medium)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + // Case 40: Effort xhigh → clamped to 32768 (matrix value) + { + name: "40", + from: "openai", + to: "claude", + model: "claude-budget-model(xhigh)", + inputJSON: `{"model":"claude-budget-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "thinking.budget_tokens", + expectValue: "32768", + expectErr: false, + }, + // Case 41: Effort none → ZeroAllowed=true → disabled + { + name: "41", + from: "openai", + to: "claude", + model: "claude-budget-model(none)", + inputJSON: `{"model":"claude-budget-model(none)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "thinking.type", + expectValue: "disabled", + expectErr: false, + }, + // Case 42: Effort auto → DynamicAllowed=false → 64512 (mid-range) + { + name: "42", + from: "openai", + to: "claude", + model: "claude-budget-model(auto)", + inputJSON: `{"model":"claude-budget-model(auto)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "thinking.budget_tokens", + expectValue: "64512", + expectErr: false, + }, + // Case 43: Gemini source no suffix → passthrough + { + name: "43", + from: "gemini", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 44: Budget 8192 → 8192 + { + name: "44", + from: "gemini", + to: "claude", + model: "claude-budget-model(8192)", + inputJSON: `{"model":"claude-budget-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + // Case 45: Budget 200000 → clamped to 128000 (max) + { + name: "45", + from: "gemini", + to: "claude", + model: "claude-budget-model(200000)", + inputJSON: `{"model":"claude-budget-model(200000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "thinking.budget_tokens", + expectValue: "128000", + expectErr: false, + }, + // Case 46: Budget 0 → ZeroAllowed=true → disabled + { + name: "46", + from: "gemini", + to: "claude", + model: "claude-budget-model(0)", + inputJSON: `{"model":"claude-budget-model(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "thinking.type", + expectValue: "disabled", + expectErr: false, + }, + // Case 47: Budget -1 → auto → DynamicAllowed=false → 64512 (mid-range) + { + name: "47", + from: "gemini", + to: "claude", + model: "claude-budget-model(-1)", + inputJSON: `{"model":"claude-budget-model(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "thinking.budget_tokens", + expectValue: "64512", + expectErr: false, + }, + + // antigravity-budget-model (Min=128, Max=20000, ZeroAllowed=true, DynamicAllowed=true) + + // Case 48: Gemini to Antigravity no suffix → passthrough + { + name: "48", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 49: Effort medium → 8192 + { + name: "49", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model(medium)", + inputJSON: `{"model":"antigravity-budget-model(medium)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 50: Effort xhigh → clamped to 20000 (max) + { + name: "50", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model(xhigh)", + inputJSON: `{"model":"antigravity-budget-model(xhigh)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 51: Effort none → ZeroAllowed=true → 0 → includeThoughts=false + { + name: "51", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model(none)", + inputJSON: `{"model":"antigravity-budget-model(none)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "0", + includeThoughts: "false", + expectErr: false, + }, + // Case 52: Effort auto → DynamicAllowed=true → -1 + { + name: "52", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model(auto)", + inputJSON: `{"model":"antigravity-budget-model(auto)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + // Case 53: Claude to Antigravity no suffix → passthrough + { + name: "53", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 54: Budget 8192 → 8192 + { + name: "54", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model(8192)", + inputJSON: `{"model":"antigravity-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 55: Budget 64000 → clamped to 20000 (max) + { + name: "55", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model(64000)", + inputJSON: `{"model":"antigravity-budget-model(64000)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 56: Budget 0 → ZeroAllowed=true → 0 → includeThoughts=false + { + name: "56", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model(0)", + inputJSON: `{"model":"antigravity-budget-model(0)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "0", + includeThoughts: "false", + expectErr: false, + }, + // Case 57: Budget -1 → DynamicAllowed=true → -1 + { + name: "57", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model(-1)", + inputJSON: `{"model":"antigravity-budget-model(-1)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + + // no-thinking-model (Thinking=nil) + + // Case 58: No thinking support → no configuration + { + name: "58", + from: "gemini", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 59: Budget 8192 → no thinking support → suffix stripped → no configuration + { + name: "59", + from: "gemini", + to: "openai", + model: "no-thinking-model(8192)", + inputJSON: `{"model":"no-thinking-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 60: Budget 0 → suffix stripped → no configuration + { + name: "60", + from: "gemini", + to: "openai", + model: "no-thinking-model(0)", + inputJSON: `{"model":"no-thinking-model(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 61: Budget -1 → suffix stripped → no configuration + { + name: "61", + from: "gemini", + to: "openai", + model: "no-thinking-model(-1)", + inputJSON: `{"model":"no-thinking-model(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 62: Claude source no suffix → no configuration + { + name: "62", + from: "claude", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 63: Budget 8192 → suffix stripped → no configuration + { + name: "63", + from: "claude", + to: "openai", + model: "no-thinking-model(8192)", + inputJSON: `{"model":"no-thinking-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 64: Budget 0 → suffix stripped → no configuration + { + name: "64", + from: "claude", + to: "openai", + model: "no-thinking-model(0)", + inputJSON: `{"model":"no-thinking-model(0)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 65: Budget -1 → suffix stripped → no configuration + { + name: "65", + from: "claude", + to: "openai", + model: "no-thinking-model(-1)", + inputJSON: `{"model":"no-thinking-model(-1)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + + // user-defined-model (UserDefined=true, Thinking=nil) + + // Case 66: User defined model no suffix → passthrough + { + name: "66", + from: "gemini", + to: "openai", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 67: Budget 8192 → passthrough logic → medium + { + name: "67", + from: "gemini", + to: "openai", + model: "user-defined-model(8192)", + inputJSON: `{"model":"user-defined-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning_effort", + expectValue: "medium", + expectErr: false, + }, + // Case 68: Budget 64000 → passthrough logic → xhigh + { + name: "68", + from: "gemini", + to: "openai", + model: "user-defined-model(64000)", + inputJSON: `{"model":"user-defined-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning_effort", + expectValue: "xhigh", + expectErr: false, + }, + // Case 69: Budget 0 → passthrough logic → none + { + name: "69", + from: "gemini", + to: "openai", + model: "user-defined-model(0)", + inputJSON: `{"model":"user-defined-model(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning_effort", + expectValue: "none", + expectErr: false, + }, + // Case 70: Budget -1 → passthrough logic → auto + { + name: "70", + from: "gemini", + to: "openai", + model: "user-defined-model(-1)", + inputJSON: `{"model":"user-defined-model(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning_effort", + expectValue: "auto", + expectErr: false, + }, + // Case 71: Claude to Codex no suffix → injected default → medium + { + name: "71", + from: "claude", + to: "codex", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 72: Budget 8192 → passthrough logic → medium + { + name: "72", + from: "claude", + to: "codex", + model: "user-defined-model(8192)", + inputJSON: `{"model":"user-defined-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 73: Budget 64000 → passthrough logic → xhigh + { + name: "73", + from: "claude", + to: "codex", + model: "user-defined-model(64000)", + inputJSON: `{"model":"user-defined-model(64000)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "xhigh", + expectErr: false, + }, + // Case 74: Budget 0 → passthrough logic → none + { + name: "74", + from: "claude", + to: "codex", + model: "user-defined-model(0)", + inputJSON: `{"model":"user-defined-model(0)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "none", + expectErr: false, + }, + // Case 75: Budget -1 → passthrough logic → auto + { + name: "75", + from: "claude", + to: "codex", + model: "user-defined-model(-1)", + inputJSON: `{"model":"user-defined-model(-1)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "auto", + expectErr: false, + }, + // Case 76: OpenAI to Gemini budget 8192 → passthrough → 8192 + { + name: "76", + from: "openai", + to: "gemini", + model: "user-defined-model(8192)", + inputJSON: `{"model":"user-defined-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 77: OpenAI to Claude budget 8192 → passthrough → 8192 + { + name: "77", + from: "openai", + to: "claude", + model: "user-defined-model(8192)", + inputJSON: `{"model":"user-defined-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + // Case 78: OpenAI-Response to Gemini budget 8192 → passthrough → 8192 + { + name: "78", + from: "openai-response", + to: "gemini", + model: "user-defined-model(8192)", + inputJSON: `{"model":"user-defined-model(8192)","input":[{"role":"user","content":"hi"}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 79: OpenAI-Response to Claude budget 8192 → passthrough → 8192 + { + name: "79", + from: "openai-response", + to: "claude", + model: "user-defined-model(8192)", + inputJSON: `{"model":"user-defined-model(8192)","input":[{"role":"user","content":"hi"}]}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + + // Same-protocol passthrough tests (80-89) + + // Case 80: OpenAI to OpenAI, level high → passthrough reasoning_effort + { + name: "80", + from: "openai", + to: "openai", + model: "level-model(high)", + inputJSON: `{"model":"level-model(high)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, + }, + // Case 81: OpenAI to OpenAI, level xhigh → out of range error + { + name: "81", + from: "openai", + to: "openai", + model: "level-model(xhigh)", + inputJSON: `{"model":"level-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: true, + }, + // Case 82: OpenAI-Response to Codex, level high → passthrough reasoning.effort + { + name: "82", + from: "openai-response", + to: "codex", + model: "level-model(high)", + inputJSON: `{"model":"level-model(high)","input":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + // Case 83: OpenAI-Response to Codex, level xhigh → out of range error + { + name: "83", + from: "openai-response", + to: "codex", + model: "level-model(xhigh)", + inputJSON: `{"model":"level-model(xhigh)","input":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: true, + }, + // Case 84: Gemini to Gemini, budget 8192 → passthrough thinkingBudget + { + name: "84", + from: "gemini", + to: "gemini", + model: "gemini-budget-model(8192)", + inputJSON: `{"model":"gemini-budget-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 85: Gemini to Gemini, budget 64000 → clamped to Max + { + name: "85", + from: "gemini", + to: "gemini", + model: "gemini-budget-model(64000)", + inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 86: Claude to Claude, budget 8192 → passthrough thinking.budget_tokens + { + name: "86", + from: "claude", + to: "claude", + model: "claude-budget-model(8192)", + inputJSON: `{"model":"claude-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + // Case 87: Claude to Claude, budget 200000 → clamped to Max + { + name: "87", + from: "claude", + to: "claude", + model: "claude-budget-model(200000)", + inputJSON: `{"model":"claude-budget-model(200000)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "thinking.budget_tokens", + expectValue: "128000", + expectErr: false, + }, + // Case 88: Gemini-CLI to Antigravity, budget 8192 → passthrough thinkingBudget + { + name: "88", + from: "gemini-cli", + to: "antigravity", + model: "antigravity-budget-model(8192)", + inputJSON: `{"model":"antigravity-budget-model(8192)","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 89: Gemini-CLI to Antigravity, budget 64000 → clamped to Max + { + name: "89", + from: "gemini-cli", + to: "antigravity", + model: "antigravity-budget-model(64000)", + inputJSON: `{"model":"antigravity-budget-model(64000)","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + + // iflow tests: glm-test and minimax-test (Cases 90-105) + + // glm-test (from: openai, claude) + // Case 90: OpenAI to iflow, no suffix → passthrough + { + name: "90", + from: "openai", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 91: OpenAI to iflow, (medium) → enable_thinking=true + { + name: "91", + from: "openai", + to: "iflow", + model: "glm-test(medium)", + inputJSON: `{"model":"glm-test(medium)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // Case 92: OpenAI to iflow, (auto) → enable_thinking=true + { + name: "92", + from: "openai", + to: "iflow", + model: "glm-test(auto)", + inputJSON: `{"model":"glm-test(auto)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // Case 93: OpenAI to iflow, (none) → enable_thinking=false + { + name: "93", + from: "openai", + to: "iflow", + model: "glm-test(none)", + inputJSON: `{"model":"glm-test(none)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "false", + expectErr: false, + }, + // Case 94: Claude to iflow, no suffix → passthrough + { + name: "94", + from: "claude", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 95: Claude to iflow, (8192) → enable_thinking=true + { + name: "95", + from: "claude", + to: "iflow", + model: "glm-test(8192)", + inputJSON: `{"model":"glm-test(8192)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // Case 96: Claude to iflow, (-1) → enable_thinking=true + { + name: "96", + from: "claude", + to: "iflow", + model: "glm-test(-1)", + inputJSON: `{"model":"glm-test(-1)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // Case 97: Claude to iflow, (0) → enable_thinking=false + { + name: "97", + from: "claude", + to: "iflow", + model: "glm-test(0)", + inputJSON: `{"model":"glm-test(0)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "false", + expectErr: false, + }, + + // minimax-test (from: openai, gemini) + // Case 98: OpenAI to iflow, no suffix → passthrough + { + name: "98", + from: "openai", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 99: OpenAI to iflow, (medium) → reasoning_split=true + { + name: "99", + from: "openai", + to: "iflow", + model: "minimax-test(medium)", + inputJSON: `{"model":"minimax-test(medium)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // Case 100: OpenAI to iflow, (auto) → reasoning_split=true + { + name: "100", + from: "openai", + to: "iflow", + model: "minimax-test(auto)", + inputJSON: `{"model":"minimax-test(auto)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // Case 101: OpenAI to iflow, (none) → reasoning_split=false + { + name: "101", + from: "openai", + to: "iflow", + model: "minimax-test(none)", + inputJSON: `{"model":"minimax-test(none)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning_split", + expectValue: "false", + expectErr: false, + }, + // Case 102: Gemini to iflow, no suffix → passthrough + { + name: "102", + from: "gemini", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 103: Gemini to iflow, (8192) → reasoning_split=true + { + name: "103", + from: "gemini", + to: "iflow", + model: "minimax-test(8192)", + inputJSON: `{"model":"minimax-test(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // Case 104: Gemini to iflow, (-1) → reasoning_split=true + { + name: "104", + from: "gemini", + to: "iflow", + model: "minimax-test(-1)", + inputJSON: `{"model":"minimax-test(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // Case 105: Gemini to iflow, (0) → reasoning_split=false + { + name: "105", + from: "gemini", + to: "iflow", + model: "minimax-test(0)", + inputJSON: `{"model":"minimax-test(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning_split", + expectValue: "false", + expectErr: false, + }, + + // Gemini Family Cross-Channel Consistency (Cases 106-114) + // Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior + + // Case 106: Gemini to Antigravity, budget 64000 (suffix) → clamped to Max + { + name: "106", + from: "gemini", + to: "antigravity", + model: "gemini-budget-model(64000)", + inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 107: Gemini to Gemini-CLI, budget 64000 (suffix) → clamped to Max + { + name: "107", + from: "gemini", + to: "gemini-cli", + model: "gemini-budget-model(64000)", + inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 108: Gemini-CLI to Antigravity, budget 64000 (suffix) → clamped to Max + { + name: "108", + from: "gemini-cli", + to: "antigravity", + model: "gemini-budget-model(64000)", + inputJSON: `{"model":"gemini-budget-model(64000)","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 109: Gemini-CLI to Gemini, budget 64000 (suffix) → clamped to Max + { + name: "109", + from: "gemini-cli", + to: "gemini", + model: "gemini-budget-model(64000)", + inputJSON: `{"model":"gemini-budget-model(64000)","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 110: Gemini to Antigravity, budget 8192 → passthrough (normal value) + { + name: "110", + from: "gemini", + to: "antigravity", + model: "gemini-budget-model(8192)", + inputJSON: `{"model":"gemini-budget-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 111: Gemini-CLI to Antigravity, budget 8192 → passthrough (normal value) + { + name: "111", + from: "gemini-cli", + to: "antigravity", + model: "gemini-budget-model(8192)", + inputJSON: `{"model":"gemini-budget-model(8192)","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + } + + runThinkingTests(t, cases) +} + +// TestThinkingE2EMatrix_Body tests the thinking configuration transformation using request body parameters. +// Data flow: Input JSON with thinking params → TranslateRequest → ApplyThinking → Validate Output +func TestThinkingE2EMatrix_Body(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("thinking-e2e-body-%d", time.Now().UnixNano()) + + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + cases := []thinkingTestCase{ + // level-model (Levels=minimal/low/medium/high, ZeroAllowed=false, DynamicAllowed=false) + + // Case 1: No param → injected default → medium + { + name: "1", + from: "openai", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 2: reasoning_effort=medium → medium + { + name: "2", + from: "openai", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 3: reasoning_effort=xhigh → out of range error + { + name: "3", + from: "openai", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "", + expectErr: true, + }, + // Case 4: reasoning_effort=none → clamped to minimal + { + name: "4", + from: "openai", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "reasoning.effort", + expectValue: "minimal", + expectErr: false, + }, + // Case 5: reasoning_effort=auto → medium (DynamicAllowed=false) + { + name: "5", + from: "openai", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"auto"}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 6: No param from gemini → injected default → medium + { + name: "6", + from: "gemini", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 7: thinkingBudget=8192 → medium + { + name: "7", + from: "gemini", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 8: thinkingBudget=64000 → clamped to high + { + name: "8", + from: "gemini", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + // Case 9: thinkingBudget=0 → clamped to minimal + { + name: "9", + from: "gemini", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, + expectField: "reasoning.effort", + expectValue: "minimal", + expectErr: false, + }, + // Case 10: thinkingBudget=-1 → medium (DynamicAllowed=false) + { + name: "10", + from: "gemini", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 11: Claude no param → passthrough (no thinking) + { + name: "11", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 12: thinking.budget_tokens=8192 → medium + { + name: "12", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, + expectField: "reasoning_effort", + expectValue: "medium", + expectErr: false, + }, + // Case 13: thinking.budget_tokens=64000 → clamped to high + { + name: "13", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, + }, + // Case 14: thinking.budget_tokens=0 → clamped to minimal + { + name: "14", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, + expectField: "reasoning_effort", + expectValue: "minimal", + expectErr: false, + }, + // Case 15: thinking.budget_tokens=-1 → medium (DynamicAllowed=false) + { + name: "15", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, + expectField: "reasoning_effort", + expectValue: "medium", + expectErr: false, + }, + + // level-subset-model (Levels=low/high, ZeroAllowed=false, DynamicAllowed=false) + + // Case 16: thinkingBudget=8192 → medium → rounded down to low + { + name: "16", + from: "gemini", + to: "openai", + model: "level-subset-model", + inputJSON: `{"model":"level-subset-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "reasoning_effort", + expectValue: "low", + expectErr: false, + }, + // Case 17: thinking.budget_tokens=1 → minimal → clamped to low + { + name: "17", + from: "claude", + to: "gemini", + model: "level-subset-model", + inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":1}}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "low", + includeThoughts: "true", + expectErr: false, + }, + + // gemini-budget-model (Min=128, Max=20000, ZeroAllowed=false, DynamicAllowed=true) + + // Case 18: No param → passthrough + { + name: "18", + from: "openai", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 19: reasoning_effort=medium → 8192 + { + name: "19", + from: "openai", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 20: reasoning_effort=xhigh → clamped to 20000 + { + name: "20", + from: "openai", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 21: reasoning_effort=none → clamped to 128 → includeThoughts=false + { + name: "21", + from: "openai", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "128", + includeThoughts: "false", + expectErr: false, + }, + // Case 22: reasoning_effort=auto → -1 (DynamicAllowed=true) + { + name: "22", + from: "openai", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"auto"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + // Case 23: Claude no param → passthrough + { + name: "23", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 24: thinking.budget_tokens=8192 → 8192 + { + name: "24", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 25: thinking.budget_tokens=64000 → clamped to 20000 + { + name: "25", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 26: thinking.budget_tokens=0 → clamped to 128 → includeThoughts=false + { + name: "26", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "128", + includeThoughts: "false", + expectErr: false, + }, + // Case 27: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) + { + name: "27", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + + // gemini-mixed-model (Min=128, Max=32768, Levels=low/high, ZeroAllowed=false, DynamicAllowed=true) + + // Case 28: No param → passthrough + { + name: "28", + from: "openai", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 29: reasoning_effort=high → high + { + name: "29", + from: "openai", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "high", + includeThoughts: "true", + expectErr: false, + }, + // Case 30: reasoning_effort=xhigh → error (not in low/high) + { + name: "30", + from: "openai", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "", + expectErr: true, + }, + // Case 31: reasoning_effort=none → clamped to low → includeThoughts=false + { + name: "31", + from: "openai", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "low", + includeThoughts: "false", + expectErr: false, + }, + // Case 32: reasoning_effort=auto → -1 (DynamicAllowed=true) + { + name: "32", + from: "openai", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"auto"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + // Case 33: Claude no param → passthrough + { + name: "33", + from: "claude", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 34: thinking.budget_tokens=8192 → 8192 (keeps budget) + { + name: "34", + from: "claude", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 35: thinking.budget_tokens=64000 → clamped to 32768 (keeps budget) + { + name: "35", + from: "claude", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "32768", + includeThoughts: "true", + expectErr: false, + }, + // Case 36: thinking.budget_tokens=0 → clamped to low → includeThoughts=false + { + name: "36", + from: "claude", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "low", + includeThoughts: "false", + expectErr: false, + }, + // Case 37: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) + { + name: "37", + from: "claude", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + + // claude-budget-model (Min=1024, Max=128000, ZeroAllowed=true, DynamicAllowed=false) + + // Case 38: No param → passthrough + { + name: "38", + from: "openai", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 39: reasoning_effort=medium → 8192 + { + name: "39", + from: "openai", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + // Case 40: reasoning_effort=xhigh → clamped to 32768 + { + name: "40", + from: "openai", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "thinking.budget_tokens", + expectValue: "32768", + expectErr: false, + }, + // Case 41: reasoning_effort=none → disabled + { + name: "41", + from: "openai", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "thinking.type", + expectValue: "disabled", + expectErr: false, + }, + // Case 42: reasoning_effort=auto → 64512 (mid-range) + { + name: "42", + from: "openai", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"auto"}`, + expectField: "thinking.budget_tokens", + expectValue: "64512", + expectErr: false, + }, + // Case 43: Gemini no param → passthrough + { + name: "43", + from: "gemini", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 44: thinkingBudget=8192 → 8192 + { + name: "44", + from: "gemini", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + // Case 45: thinkingBudget=200000 → clamped to 128000 + { + name: "45", + from: "gemini", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":200000}}}`, + expectField: "thinking.budget_tokens", + expectValue: "128000", + expectErr: false, + }, + // Case 46: thinkingBudget=0 → disabled + { + name: "46", + from: "gemini", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, + expectField: "thinking.type", + expectValue: "disabled", + expectErr: false, + }, + // Case 47: thinkingBudget=-1 → 64512 (mid-range) + { + name: "47", + from: "gemini", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, + expectField: "thinking.budget_tokens", + expectValue: "64512", + expectErr: false, + }, + + // antigravity-budget-model (Min=128, Max=20000, ZeroAllowed=true, DynamicAllowed=true) + + // Case 48: Gemini no param → passthrough + { + name: "48", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 49: thinkingLevel=medium → 8192 + { + name: "49", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"}}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 50: thinkingLevel=xhigh → clamped to 20000 + { + name: "50", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 51: thinkingLevel=none → 0 (ZeroAllowed=true) + { + name: "51", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"none"}}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "0", + includeThoughts: "false", + expectErr: false, + }, + // Case 52: thinkingBudget=-1 → -1 (DynamicAllowed=true) + { + name: "52", + from: "gemini", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + // Case 53: Claude no param → passthrough + { + name: "53", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 54: thinking.budget_tokens=8192 → 8192 + { + name: "54", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 55: thinking.budget_tokens=64000 → clamped to 20000 + { + name: "55", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // Case 56: thinking.budget_tokens=0 → 0 (ZeroAllowed=true) + { + name: "56", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "0", + includeThoughts: "false", + expectErr: false, + }, + // Case 57: thinking.budget_tokens=-1 → -1 (DynamicAllowed=true) + { + name: "57", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "-1", + includeThoughts: "true", + expectErr: false, + }, + + // no-thinking-model (Thinking=nil) + + // Case 58: Gemini no param → passthrough + { + name: "58", + from: "gemini", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 59: thinkingBudget=8192 → stripped + { + name: "59", + from: "gemini", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "", + expectErr: false, + }, + // Case 60: thinkingBudget=0 → stripped + { + name: "60", + from: "gemini", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, + expectField: "", + expectErr: false, + }, + // Case 61: thinkingBudget=-1 → stripped + { + name: "61", + from: "gemini", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, + expectField: "", + expectErr: false, + }, + // Case 62: Claude no param → passthrough + { + name: "62", + from: "claude", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 63: thinking.budget_tokens=8192 → stripped + { + name: "63", + from: "claude", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, + expectField: "", + expectErr: false, + }, + // Case 64: thinking.budget_tokens=0 → stripped + { + name: "64", + from: "claude", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, + expectField: "", + expectErr: false, + }, + // Case 65: thinking.budget_tokens=-1 → stripped + { + name: "65", + from: "claude", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, + expectField: "", + expectErr: false, + }, + + // user-defined-model (UserDefined=true, Thinking=nil) + + // Case 66: Gemini no param → passthrough + { + name: "66", + from: "gemini", + to: "openai", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 67: thinkingBudget=8192 → medium + { + name: "67", + from: "gemini", + to: "openai", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "reasoning_effort", + expectValue: "medium", + expectErr: false, + }, + // Case 68: thinkingBudget=64000 → xhigh (passthrough) + { + name: "68", + from: "gemini", + to: "openai", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}`, + expectField: "reasoning_effort", + expectValue: "xhigh", + expectErr: false, + }, + // Case 69: thinkingBudget=0 → none + { + name: "69", + from: "gemini", + to: "openai", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, + expectField: "reasoning_effort", + expectValue: "none", + expectErr: false, + }, + // Case 70: thinkingBudget=-1 → auto + { + name: "70", + from: "gemini", + to: "openai", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, + expectField: "reasoning_effort", + expectValue: "auto", + expectErr: false, + }, + // Case 71: Claude no param → injected default → medium + { + name: "71", + from: "claude", + to: "codex", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}]}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 72: thinking.budget_tokens=8192 → medium + { + name: "72", + from: "claude", + to: "codex", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, + expectField: "reasoning.effort", + expectValue: "medium", + expectErr: false, + }, + // Case 73: thinking.budget_tokens=64000 → xhigh (passthrough) + { + name: "73", + from: "claude", + to: "codex", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":64000}}`, + expectField: "reasoning.effort", + expectValue: "xhigh", + expectErr: false, + }, + // Case 74: thinking.budget_tokens=0 → none + { + name: "74", + from: "claude", + to: "codex", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, + expectField: "reasoning.effort", + expectValue: "none", + expectErr: false, + }, + // Case 75: thinking.budget_tokens=-1 → auto + { + name: "75", + from: "claude", + to: "codex", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, + expectField: "reasoning.effort", + expectValue: "auto", + expectErr: false, + }, + // Case 76: OpenAI reasoning_effort=medium to Gemini → 8192 + { + name: "76", + from: "openai", + to: "gemini", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 77: OpenAI reasoning_effort=medium to Claude → 8192 + { + name: "77", + from: "openai", + to: "claude", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + // Case 78: OpenAI-Response reasoning.effort=medium to Gemini → 8192 + { + name: "78", + from: "openai-response", + to: "gemini", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"medium"}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 79: OpenAI-Response reasoning.effort=medium to Claude → 8192 + { + name: "79", + from: "openai-response", + to: "claude", + model: "user-defined-model", + inputJSON: `{"model":"user-defined-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"medium"}}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + + // Same-protocol passthrough tests (80-89) + + // Case 80: OpenAI to OpenAI, reasoning_effort=high → passthrough + { + name: "80", + from: "openai", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, + }, + // Case 81: OpenAI to OpenAI, reasoning_effort=xhigh → out of range error + { + name: "81", + from: "openai", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`, + expectField: "", + expectErr: true, + }, + // Case 82: OpenAI-Response to Codex, reasoning.effort=high → passthrough + { + name: "82", + from: "openai-response", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"high"}}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + // Case 83: OpenAI-Response to Codex, reasoning.effort=xhigh → out of range error + { + name: "83", + from: "openai-response", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"xhigh"}}`, + expectField: "", + expectErr: true, + }, + // Case 84: Gemini to Gemini, thinkingBudget=8192 → passthrough + { + name: "84", + from: "gemini", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 85: Gemini to Gemini, thinkingBudget=64000 → exceeds Max error + { + name: "85", + from: "gemini", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}`, + expectField: "", + expectErr: true, + }, + // Case 86: Claude to Claude, thinking.budget_tokens=8192 → passthrough + { + name: "86", + from: "claude", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, + expectField: "thinking.budget_tokens", + expectValue: "8192", + expectErr: false, + }, + // Case 87: Claude to Claude, thinking.budget_tokens=200000 → exceeds Max error + { + name: "87", + from: "claude", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":200000}}`, + expectField: "", + expectErr: true, + }, + // Case 88: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough + { + name: "88", + from: "gemini-cli", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 89: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error + { + name: "89", + from: "gemini-cli", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}}`, + expectField: "", + expectErr: true, + }, + + // iflow tests: glm-test and minimax-test (Cases 90-105) + + // glm-test (from: openai, claude) + // Case 90: OpenAI to iflow, no param → passthrough + { + name: "90", + from: "openai", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 91: OpenAI to iflow, reasoning_effort=medium → enable_thinking=true + { + name: "91", + from: "openai", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // Case 92: OpenAI to iflow, reasoning_effort=auto → enable_thinking=true + { + name: "92", + from: "openai", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"auto"}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // Case 93: OpenAI to iflow, reasoning_effort=none → enable_thinking=false + { + name: "93", + from: "openai", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "false", + expectErr: false, + }, + // Case 94: Claude to iflow, no param → passthrough + { + name: "94", + from: "claude", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 95: Claude to iflow, thinking.budget_tokens=8192 → enable_thinking=true + { + name: "95", + from: "claude", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8192}}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // Case 96: Claude to iflow, thinking.budget_tokens=-1 → enable_thinking=true + { + name: "96", + from: "claude", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":-1}}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // Case 97: Claude to iflow, thinking.budget_tokens=0 → enable_thinking=false + { + name: "97", + from: "claude", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0}}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "false", + expectErr: false, + }, + + // minimax-test (from: openai, gemini) + // Case 98: OpenAI to iflow, no param → passthrough + { + name: "98", + from: "openai", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: false, + }, + // Case 99: OpenAI to iflow, reasoning_effort=medium → reasoning_split=true + { + name: "99", + from: "openai", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // Case 100: OpenAI to iflow, reasoning_effort=auto → reasoning_split=true + { + name: "100", + from: "openai", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"auto"}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // Case 101: OpenAI to iflow, reasoning_effort=none → reasoning_split=false + { + name: "101", + from: "openai", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`, + expectField: "reasoning_split", + expectValue: "false", + expectErr: false, + }, + // Case 102: Gemini to iflow, no param → passthrough + { + name: "102", + from: "gemini", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: false, + }, + // Case 103: Gemini to iflow, thinkingBudget=8192 → reasoning_split=true + { + name: "103", + from: "gemini", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // Case 104: Gemini to iflow, thinkingBudget=-1 → reasoning_split=true + { + name: "104", + from: "gemini", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // Case 105: Gemini to iflow, thinkingBudget=0 → reasoning_split=false + { + name: "105", + from: "gemini", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, + expectField: "reasoning_split", + expectValue: "false", + expectErr: false, + }, + + // Gemini Family Cross-Channel Consistency (Cases 106-114) + // Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior + + // Case 106: Gemini to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) + { + name: "106", + from: "gemini", + to: "antigravity", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}`, + expectField: "", + expectErr: true, + }, + // Case 107: Gemini to Gemini-CLI, thinkingBudget=64000 → exceeds Max error (same family strict validation) + { + name: "107", + from: "gemini", + to: "gemini-cli", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}`, + expectField: "", + expectErr: true, + }, + // Case 108: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation) + { + name: "108", + from: "gemini-cli", + to: "antigravity", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}}`, + expectField: "", + expectErr: true, + }, + // Case 109: Gemini-CLI to Gemini, thinkingBudget=64000 → exceeds Max error (same family strict validation) + { + name: "109", + from: "gemini-cli", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}}`, + expectField: "", + expectErr: true, + }, + // Case 110: Gemini to Antigravity, thinkingBudget=8192 → passthrough (normal value) + { + name: "110", + from: "gemini", + to: "antigravity", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + // Case 111: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough (normal value) + { + name: "111", + from: "gemini-cli", + to: "antigravity", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "8192", + includeThoughts: "true", + expectErr: false, + }, + } + + runThinkingTests(t, cases) +} + +// getTestModels returns the shared model definitions for E2E tests. +func getTestModels() []*registry.ModelInfo { + return []*registry.ModelInfo{ { ID: "level-model", Object: "model", @@ -38,11 +2598,16 @@ func TestThinkingE2EMatrix(t *testing.T) { OwnedBy: "test", Type: "openai", DisplayName: "Level Model", - Thinking: ®istry.ThinkingSupport{ - Levels: []string{"minimal", "low", "medium", "high"}, - ZeroAllowed: false, - DynamicAllowed: false, - }, + Thinking: ®istry.ThinkingSupport{Levels: []string{"minimal", "low", "medium", "high"}, ZeroAllowed: false, DynamicAllowed: false}, + }, + { + ID: "level-subset-model", + Object: "model", + Created: 1700000000, + OwnedBy: "test", + Type: "gemini", + DisplayName: "Level Subset Model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "high"}, ZeroAllowed: false, DynamicAllowed: false}, }, { ID: "gemini-budget-model", @@ -51,12 +2616,7 @@ func TestThinkingE2EMatrix(t *testing.T) { OwnedBy: "test", Type: "gemini", DisplayName: "Gemini Budget Model", - Thinking: ®istry.ThinkingSupport{ - Min: 128, - Max: 20000, - ZeroAllowed: false, - DynamicAllowed: true, - }, + Thinking: ®istry.ThinkingSupport{Min: 128, Max: 20000, ZeroAllowed: false, DynamicAllowed: true}, }, { ID: "gemini-mixed-model", @@ -65,13 +2625,7 @@ func TestThinkingE2EMatrix(t *testing.T) { OwnedBy: "test", Type: "gemini", DisplayName: "Gemini Mixed Model", - Thinking: ®istry.ThinkingSupport{ - Min: 128, - Max: 32768, - Levels: []string{"low", "high"}, - ZeroAllowed: false, - DynamicAllowed: true, - }, + Thinking: ®istry.ThinkingSupport{Min: 128, Max: 32768, Levels: []string{"low", "high"}, ZeroAllowed: false, DynamicAllowed: true}, }, { ID: "claude-budget-model", @@ -80,12 +2634,7 @@ func TestThinkingE2EMatrix(t *testing.T) { OwnedBy: "test", Type: "claude", DisplayName: "Claude Budget Model", - Thinking: ®istry.ThinkingSupport{ - Min: 1024, - Max: 128000, - ZeroAllowed: true, - DynamicAllowed: false, - }, + Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false}, }, { ID: "antigravity-budget-model", @@ -94,12 +2643,7 @@ func TestThinkingE2EMatrix(t *testing.T) { OwnedBy: "test", Type: "gemini-cli", DisplayName: "Antigravity Budget Model", - Thinking: ®istry.ThinkingSupport{ - Min: 128, - Max: 20000, - ZeroAllowed: true, - DynamicAllowed: true, - }, + Thinking: ®istry.ThinkingSupport{Min: 128, Max: 20000, ZeroAllowed: true, DynamicAllowed: true}, }, { ID: "no-thinking-model", @@ -120,877 +2664,56 @@ func TestThinkingE2EMatrix(t *testing.T) { UserDefined: true, Thinking: nil, }, - } - - reg.RegisterClient(uid, "test", testModels) - defer reg.UnregisterClient(uid) - - type testCase struct { - name string - from string - to string - modelSuffix string - inputJSON string - expectField string - expectValue string - includeThoughts string - expectErr bool - } - - cases := []testCase{ - // level-model (Levels=minimal/low/medium/high, ZeroAllowed=false, DynamicAllowed=false) - // Case 1: No suffix, translator adds default medium for codex { - name: "1", - from: "openai", - to: "codex", - modelSuffix: "level-model", - inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "medium", - expectErr: false, - }, - // Case 2: Explicit medium level - { - name: "2", - from: "openai", - to: "codex", - modelSuffix: "level-model(medium)", - inputJSON: `{"model":"level-model(medium)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "medium", - expectErr: false, - }, - // Case 3: xhigh not in Levels=[minimal,low,medium,high] → ValidateConfig returns error - { - name: "3", - from: "openai", - to: "codex", - modelSuffix: "level-model(xhigh)", - inputJSON: `{"model":"level-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: true, - }, - // Case 4: none → ModeNone, ZeroAllowed=false → clamp to min level (minimal) - { - name: "4", - from: "openai", - to: "codex", - modelSuffix: "level-model(none)", - inputJSON: `{"model":"level-model(none)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "minimal", - expectErr: false, - }, - // Case 5: auto → ModeAuto, DynamicAllowed=false → convert to mid-range (medium) - { - name: "5", - from: "openai", - to: "codex", - modelSuffix: "level-model(auto)", - inputJSON: `{"model":"level-model(auto)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "medium", - expectErr: false, - }, - // Case 6: No suffix from gemini → translator injects default reasoning.effort: medium - { - name: "6", - from: "gemini", - to: "codex", - modelSuffix: "level-model", - inputJSON: `{"model":"level-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning.effort", - expectValue: "medium", - expectErr: false, - }, - // Case 7: 8192 → medium (1025-8192) - { - name: "7", - from: "gemini", - to: "codex", - modelSuffix: "level-model(8192)", - inputJSON: `{"model":"level-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning.effort", - expectValue: "medium", - expectErr: false, - }, - // Case 8: 64000 → xhigh → not supported → error - { - name: "8", - from: "gemini", - to: "codex", - modelSuffix: "level-model(64000)", - inputJSON: `{"model":"level-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: true, - }, - // Case 9: 0 → ModeNone, ZeroAllowed=false → clamp to min level (minimal) - { - name: "9", - from: "gemini", - to: "codex", - modelSuffix: "level-model(0)", - inputJSON: `{"model":"level-model(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning.effort", - expectValue: "minimal", - expectErr: false, - }, - // Case 10: -1 → ModeAuto, DynamicAllowed=false → convert to mid-range (medium) - { - name: "10", - from: "gemini", - to: "codex", - modelSuffix: "level-model(-1)", - inputJSON: `{"model":"level-model(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning.effort", - expectValue: "medium", - expectErr: false, - }, - // Case 11: No suffix from claude → no thinking config - { - name: "11", - from: "claude", - to: "openai", - modelSuffix: "level-model", - inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // Case 12: 8192 → medium - { - name: "12", - from: "claude", - to: "openai", - modelSuffix: "level-model(8192)", - inputJSON: `{"model":"level-model(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning_effort", - expectValue: "medium", - expectErr: false, - }, - // Case 13: 64000 → xhigh → not supported → error - { - name: "13", - from: "claude", - to: "openai", - modelSuffix: "level-model(64000)", - inputJSON: `{"model":"level-model(64000)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: true, - }, - // Case 14: 0 → ModeNone, ZeroAllowed=false → clamp to min level (minimal) - { - name: "14", - from: "claude", - to: "openai", - modelSuffix: "level-model(0)", - inputJSON: `{"model":"level-model(0)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning_effort", - expectValue: "minimal", - expectErr: false, - }, - // Case 15: -1 → ModeAuto, DynamicAllowed=false → convert to mid-range (medium) - { - name: "15", - from: "claude", - to: "openai", - modelSuffix: "level-model(-1)", - inputJSON: `{"model":"level-model(-1)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning_effort", - expectValue: "medium", - expectErr: false, - }, - - // gemini-budget-model (Min=128, Max=20000, ZeroAllowed=false, DynamicAllowed=true) - { - name: "16", - from: "openai", - to: "gemini", - modelSuffix: "gemini-budget-model", - inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // medium → 8192 - { - name: "17", - from: "openai", - to: "gemini", - modelSuffix: "gemini-budget-model(medium)", - inputJSON: `{"model":"gemini-budget-model(medium)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "8192", - includeThoughts: "true", - expectErr: false, - }, - // xhigh → 32768 → clamp to 20000 - { - name: "18", - from: "openai", - to: "gemini", - modelSuffix: "gemini-budget-model(xhigh)", - inputJSON: `{"model":"gemini-budget-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "20000", - includeThoughts: "true", - expectErr: false, - }, - // none → 0 → ZeroAllowed=false → clamp to 128, includeThoughts=false - { - name: "19", - from: "openai", - to: "gemini", - modelSuffix: "gemini-budget-model(none)", - inputJSON: `{"model":"gemini-budget-model(none)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "128", - includeThoughts: "false", - expectErr: false, - }, - // auto → -1 dynamic allowed - { - name: "20", - from: "openai", - to: "gemini", - modelSuffix: "gemini-budget-model(auto)", - inputJSON: `{"model":"gemini-budget-model(auto)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "-1", - includeThoughts: "true", - expectErr: false, - }, - { - name: "21", - from: "claude", - to: "gemini", - modelSuffix: "gemini-budget-model", - inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - { - name: "22", - from: "claude", - to: "gemini", - modelSuffix: "gemini-budget-model(8192)", - inputJSON: `{"model":"gemini-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "8192", - includeThoughts: "true", - expectErr: false, - }, - { - name: "23", - from: "claude", - to: "gemini", - modelSuffix: "gemini-budget-model(64000)", - inputJSON: `{"model":"gemini-budget-model(64000)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "20000", - includeThoughts: "true", - expectErr: false, - }, - { - name: "24", - from: "claude", - to: "gemini", - modelSuffix: "gemini-budget-model(0)", - inputJSON: `{"model":"gemini-budget-model(0)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "128", - includeThoughts: "false", - expectErr: false, - }, - { - name: "25", - from: "claude", - to: "gemini", - modelSuffix: "gemini-budget-model(-1)", - inputJSON: `{"model":"gemini-budget-model(-1)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "-1", - includeThoughts: "true", - expectErr: false, - }, - - // gemini-mixed-model (Min=128, Max=32768, Levels=low/high, ZeroAllowed=false, DynamicAllowed=true) - { - name: "26", - from: "openai", - to: "gemini", - modelSuffix: "gemini-mixed-model", - inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // high → use thinkingLevel - { - name: "27", - from: "openai", - to: "gemini", - modelSuffix: "gemini-mixed-model(high)", - inputJSON: `{"model":"gemini-mixed-model(high)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingLevel", - expectValue: "high", - includeThoughts: "true", - expectErr: false, - }, - // xhigh → not in Levels=[low,high] → error - { - name: "28", - from: "openai", - to: "gemini", - modelSuffix: "gemini-mixed-model(xhigh)", - inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: true, - }, - // none → ModeNone, ZeroAllowed=false → set Level to lowest (low), includeThoughts=false - { - name: "29", - from: "openai", - to: "gemini", - modelSuffix: "gemini-mixed-model(none)", - inputJSON: `{"model":"gemini-mixed-model(none)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingLevel", - expectValue: "low", - includeThoughts: "false", - expectErr: false, - }, - // auto → dynamic allowed, use thinkingBudget=-1 - { - name: "30", - from: "openai", - to: "gemini", - modelSuffix: "gemini-mixed-model(auto)", - inputJSON: `{"model":"gemini-mixed-model(auto)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "-1", - includeThoughts: "true", - expectErr: false, - }, - { - name: "31", - from: "claude", - to: "gemini", - modelSuffix: "gemini-mixed-model", - inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // 8192 → ModeBudget → clamp (in range) → thinkingBudget: 8192 - { - name: "32", - from: "claude", - to: "gemini", - modelSuffix: "gemini-mixed-model(8192)", - inputJSON: `{"model":"gemini-mixed-model(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "8192", - includeThoughts: "true", - expectErr: false, - }, - // 64000 → ModeBudget → clamp to 32768 → thinkingBudget: 32768 - { - name: "33", - from: "claude", - to: "gemini", - modelSuffix: "gemini-mixed-model(64000)", - inputJSON: `{"model":"gemini-mixed-model(64000)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "32768", - includeThoughts: "true", - expectErr: false, - }, - // 0 → ModeNone, ZeroAllowed=false → set Level to lowest (low), includeThoughts=false - { - name: "34", - from: "claude", - to: "gemini", - modelSuffix: "gemini-mixed-model(0)", - inputJSON: `{"model":"gemini-mixed-model(0)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingLevel", - expectValue: "low", - includeThoughts: "false", - expectErr: false, - }, - // -1 → auto, dynamic allowed - { - name: "35", - from: "claude", - to: "gemini", - modelSuffix: "gemini-mixed-model(-1)", - inputJSON: `{"model":"gemini-mixed-model(-1)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "-1", - includeThoughts: "true", - expectErr: false, - }, - - // claude-budget-model (Min=1024, Max=128000, ZeroAllowed=true, DynamicAllowed=false) - { - name: "36", - from: "openai", - to: "claude", - modelSuffix: "claude-budget-model", - inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - // medium → 8192 - { - name: "37", - from: "openai", - to: "claude", - modelSuffix: "claude-budget-model(medium)", - inputJSON: `{"model":"claude-budget-model(medium)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "thinking.budget_tokens", - expectValue: "8192", - expectErr: false, - }, - // xhigh → 32768 - { - name: "38", - from: "openai", - to: "claude", - modelSuffix: "claude-budget-model(xhigh)", - inputJSON: `{"model":"claude-budget-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "thinking.budget_tokens", - expectValue: "32768", - expectErr: false, - }, - // none → ZeroAllowed=true → disabled - { - name: "39", - from: "openai", - to: "claude", - modelSuffix: "claude-budget-model(none)", - inputJSON: `{"model":"claude-budget-model(none)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "thinking.type", - expectValue: "disabled", - expectErr: false, - }, - // auto → ModeAuto, DynamicAllowed=false → convert to mid-range - { - name: "40", - from: "openai", - to: "claude", - modelSuffix: "claude-budget-model(auto)", - inputJSON: `{"model":"claude-budget-model(auto)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "thinking.budget_tokens", - expectValue: "64512", - expectErr: false, - }, - { - name: "41", - from: "gemini", - to: "claude", - modelSuffix: "claude-budget-model", - inputJSON: `{"model":"claude-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - { - name: "42", - from: "gemini", - to: "claude", - modelSuffix: "claude-budget-model(8192)", - inputJSON: `{"model":"claude-budget-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "thinking.budget_tokens", - expectValue: "8192", - expectErr: false, - }, - { - name: "43", - from: "gemini", - to: "claude", - modelSuffix: "claude-budget-model(200000)", - inputJSON: `{"model":"claude-budget-model(200000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "thinking.budget_tokens", - expectValue: "128000", - expectErr: false, - }, - // 0 → ZeroAllowed=true → disabled - { - name: "44", - from: "gemini", - to: "claude", - modelSuffix: "claude-budget-model(0)", - inputJSON: `{"model":"claude-budget-model(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "thinking.type", - expectValue: "disabled", - expectErr: false, - }, - // -1 → auto → DynamicAllowed=false → mid-range - { - name: "45", - from: "gemini", - to: "claude", - modelSuffix: "claude-budget-model(-1)", - inputJSON: `{"model":"claude-budget-model(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "thinking.budget_tokens", - expectValue: "64512", - expectErr: false, - }, - - // antigravity-budget-model (Min=128, Max=20000, ZeroAllowed=true, DynamicAllowed=true) - { - name: "46", - from: "gemini", - to: "antigravity", - modelSuffix: "antigravity-budget-model", - inputJSON: `{"model":"antigravity-budget-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - { - name: "47", - from: "gemini", - to: "antigravity", - modelSuffix: "antigravity-budget-model(medium)", - inputJSON: `{"model":"antigravity-budget-model(medium)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "request.generationConfig.thinkingConfig.thinkingBudget", - expectValue: "8192", - includeThoughts: "true", - expectErr: false, - }, - { - name: "48", - from: "gemini", - to: "antigravity", - modelSuffix: "antigravity-budget-model(xhigh)", - inputJSON: `{"model":"antigravity-budget-model(xhigh)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "request.generationConfig.thinkingConfig.thinkingBudget", - expectValue: "20000", - includeThoughts: "true", - expectErr: false, - }, - { - name: "49", - from: "gemini", - to: "antigravity", - modelSuffix: "antigravity-budget-model(none)", - inputJSON: `{"model":"antigravity-budget-model(none)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "request.generationConfig.thinkingConfig.thinkingBudget", - expectValue: "0", - includeThoughts: "false", - expectErr: false, - }, - { - name: "50", - from: "gemini", - to: "antigravity", - modelSuffix: "antigravity-budget-model(auto)", - inputJSON: `{"model":"antigravity-budget-model(auto)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "request.generationConfig.thinkingConfig.thinkingBudget", - expectValue: "-1", - includeThoughts: "true", - expectErr: false, - }, - { - name: "51", - from: "claude", - to: "antigravity", - modelSuffix: "antigravity-budget-model", - inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - { - name: "52", - from: "claude", - to: "antigravity", - modelSuffix: "antigravity-budget-model(8192)", - inputJSON: `{"model":"antigravity-budget-model(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "request.generationConfig.thinkingConfig.thinkingBudget", - expectValue: "8192", - includeThoughts: "true", - expectErr: false, - }, - { - name: "53", - from: "claude", - to: "antigravity", - modelSuffix: "antigravity-budget-model(64000)", - inputJSON: `{"model":"antigravity-budget-model(64000)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "request.generationConfig.thinkingConfig.thinkingBudget", - expectValue: "20000", - includeThoughts: "true", - expectErr: false, - }, - { - name: "54", - from: "claude", - to: "antigravity", - modelSuffix: "antigravity-budget-model(0)", - inputJSON: `{"model":"antigravity-budget-model(0)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "request.generationConfig.thinkingConfig.thinkingBudget", - expectValue: "0", - includeThoughts: "false", - expectErr: false, - }, - { - name: "55", - from: "claude", - to: "antigravity", - modelSuffix: "antigravity-budget-model(-1)", - inputJSON: `{"model":"antigravity-budget-model(-1)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "request.generationConfig.thinkingConfig.thinkingBudget", - expectValue: "-1", - includeThoughts: "true", - expectErr: false, - }, - - // no-thinking-model (Thinking=nil) - { - name: "46", - from: "gemini", - to: "openai", - modelSuffix: "no-thinking-model", - inputJSON: `{"model":"no-thinking-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - { - name: "47", - from: "gemini", - to: "openai", - modelSuffix: "no-thinking-model(8192)", - inputJSON: `{"model":"no-thinking-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - { - name: "48", - from: "gemini", - to: "openai", - modelSuffix: "no-thinking-model(0)", - inputJSON: `{"model":"no-thinking-model(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - { - name: "49", - from: "gemini", - to: "openai", - modelSuffix: "no-thinking-model(-1)", - inputJSON: `{"model":"no-thinking-model(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - { - name: "50", - from: "claude", - to: "openai", - modelSuffix: "no-thinking-model", - inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - { - name: "51", - from: "claude", - to: "openai", - modelSuffix: "no-thinking-model(8192)", - inputJSON: `{"model":"no-thinking-model(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - { - name: "52", - from: "claude", - to: "openai", - modelSuffix: "no-thinking-model(0)", - inputJSON: `{"model":"no-thinking-model(0)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - { - name: "53", - from: "claude", - to: "openai", - modelSuffix: "no-thinking-model(-1)", - inputJSON: `{"model":"no-thinking-model(-1)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "", - expectErr: false, - }, - - // user-defined-model (UserDefined=true, Thinking=nil) - { - name: "54", - from: "gemini", - to: "openai", - modelSuffix: "user-defined-model", - inputJSON: `{"model":"user-defined-model","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "", - expectErr: false, - }, - // 8192 → medium (passthrough for UserDefined) - { - name: "55", - from: "gemini", - to: "openai", - modelSuffix: "user-defined-model(8192)", - inputJSON: `{"model":"user-defined-model(8192)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning_effort", - expectValue: "medium", - expectErr: false, - }, - // 64000 → xhigh - { - name: "56", - from: "gemini", - to: "openai", - modelSuffix: "user-defined-model(64000)", - inputJSON: `{"model":"user-defined-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning_effort", - expectValue: "xhigh", - expectErr: false, - }, - // 0 → none - { - name: "57", - from: "gemini", - to: "openai", - modelSuffix: "user-defined-model(0)", - inputJSON: `{"model":"user-defined-model(0)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning_effort", - expectValue: "none", - expectErr: false, - }, - // -1 → auto - { - name: "58", - from: "gemini", - to: "openai", - modelSuffix: "user-defined-model(-1)", - inputJSON: `{"model":"user-defined-model(-1)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, - expectField: "reasoning_effort", - expectValue: "auto", - expectErr: false, - }, - // Case 59: No suffix from claude → translator injects default reasoning.effort: medium - { - name: "59", - from: "claude", - to: "codex", - modelSuffix: "user-defined-model", - inputJSON: `{"model":"user-defined-model","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "medium", - expectErr: false, - }, - // 8192 → medium - { - name: "60", - from: "claude", - to: "codex", - modelSuffix: "user-defined-model(8192)", - inputJSON: `{"model":"user-defined-model(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "medium", - expectErr: false, - }, - // 64000 → xhigh - { - name: "61", - from: "claude", - to: "codex", - modelSuffix: "user-defined-model(64000)", - inputJSON: `{"model":"user-defined-model(64000)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "xhigh", - expectErr: false, - }, - // 0 → none - { - name: "62", - from: "claude", - to: "codex", - modelSuffix: "user-defined-model(0)", - inputJSON: `{"model":"user-defined-model(0)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "none", - expectErr: false, - }, - // -1 → auto - { - name: "63", - from: "claude", - to: "codex", - modelSuffix: "user-defined-model(-1)", - inputJSON: `{"model":"user-defined-model(-1)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "reasoning.effort", - expectValue: "auto", - expectErr: false, - }, - // openai/codex → gemini/claude for user-defined-model - { - name: "64", - from: "openai", - to: "gemini", - modelSuffix: "user-defined-model(8192)", - inputJSON: `{"model":"user-defined-model(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "8192", - includeThoughts: "true", - expectErr: false, - }, - { - name: "65", - from: "openai", - to: "claude", - modelSuffix: "user-defined-model(8192)", - inputJSON: `{"model":"user-defined-model(8192)","messages":[{"role":"user","content":"hi"}]}`, - expectField: "thinking.budget_tokens", - expectValue: "8192", - expectErr: false, - }, - { - name: "66", - from: "codex", - to: "gemini", - modelSuffix: "user-defined-model(8192)", - inputJSON: `{"model":"user-defined-model(8192)","input":[{"role":"user","content":"hi"}]}`, - expectField: "generationConfig.thinkingConfig.thinkingBudget", - expectValue: "8192", - includeThoughts: "true", - expectErr: false, - }, - { - name: "67", - from: "codex", - to: "claude", - modelSuffix: "user-defined-model(8192)", - inputJSON: `{"model":"user-defined-model(8192)","input":[{"role":"user","content":"hi"}]}`, - expectField: "thinking.budget_tokens", - expectValue: "8192", - expectErr: false, + ID: "glm-test", + Object: "model", + Created: 1700000000, + OwnedBy: "test", + Type: "iflow", + DisplayName: "GLM Test Model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"none", "auto", "minimal", "low", "medium", "high", "xhigh"}}, + }, + { + ID: "minimax-test", + Object: "model", + Created: 1700000000, + OwnedBy: "test", + Type: "iflow", + DisplayName: "MiniMax Test Model", + Thinking: ®istry.ThinkingSupport{Levels: []string{"none", "auto", "minimal", "low", "medium", "high", "xhigh"}}, }, } +} +// runThinkingTests runs thinking test cases using the real data flow path. +func runThinkingTests(t *testing.T, cases []thinkingTestCase) { for _, tc := range cases { tc := tc - testName := fmt.Sprintf("Case%s_%s->%s_%s", tc.name, tc.from, tc.to, tc.modelSuffix) + testName := fmt.Sprintf("Case%s_%s->%s_%s", tc.name, tc.from, tc.to, tc.model) t.Run(testName, func(t *testing.T) { - // Real data flow path: - // 1. Parse suffix to get base model - suffixResult := thinking.ParseSuffix(tc.modelSuffix) + suffixResult := thinking.ParseSuffix(tc.model) baseModel := suffixResult.ModelName - // 2. Translate request from source format to target format + translateTo := tc.to + applyTo := tc.to + if tc.to == "iflow" { + translateTo = "openai" + applyTo = "iflow" + } + body := sdktranslator.TranslateRequest( sdktranslator.FromString(tc.from), - sdktranslator.FromString(tc.to), + sdktranslator.FromString(translateTo), baseModel, []byte(tc.inputJSON), true, ) + if applyTo == "claude" { + body, _ = sjson.SetBytes(body, "max_tokens", 200000) + } - // 3. Apply thinking configuration (main entry point) - body, err := thinking.ApplyThinking(body, tc.modelSuffix, tc.to) + body, err := thinking.ApplyThinking(body, tc.model, tc.from, applyTo) - // Validate results if tc.expectErr { if err == nil { t.Fatalf("expected error but got none, body=%s", string(body)) @@ -1001,18 +2724,23 @@ func TestThinkingE2EMatrix(t *testing.T) { t.Fatalf("unexpected error: %v, body=%s", err, string(body)) } - // Check for expected field absence if tc.expectField == "" { var hasThinking bool switch tc.to { case "gemini": hasThinking = gjson.GetBytes(body, "generationConfig.thinkingConfig").Exists() + case "gemini-cli": + hasThinking = gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() + case "antigravity": + hasThinking = gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() case "claude": hasThinking = gjson.GetBytes(body, "thinking").Exists() case "openai": hasThinking = gjson.GetBytes(body, "reasoning_effort").Exists() case "codex": hasThinking = gjson.GetBytes(body, "reasoning.effort").Exists() || gjson.GetBytes(body, "reasoning").Exists() + case "iflow": + hasThinking = gjson.GetBytes(body, "chat_template_kwargs.enable_thinking").Exists() || gjson.GetBytes(body, "reasoning_split").Exists() } if hasThinking { t.Fatalf("expected no thinking field but found one, body=%s", string(body)) @@ -1020,7 +2748,6 @@ func TestThinkingE2EMatrix(t *testing.T) { return } - // Check expected field value val := gjson.GetBytes(body, tc.expectField) if !val.Exists() { t.Fatalf("expected field %s not found, body=%s", tc.expectField, string(body)) @@ -1034,10 +2761,9 @@ func TestThinkingE2EMatrix(t *testing.T) { t.Fatalf("field %s: expected %q, got %q, body=%s", tc.expectField, tc.expectValue, actualValue, string(body)) } - // Check includeThoughts for Gemini/Antigravity - if tc.includeThoughts != "" && (tc.to == "gemini" || tc.to == "antigravity") { + if tc.includeThoughts != "" && (tc.to == "gemini" || tc.to == "gemini-cli" || tc.to == "antigravity") { path := "generationConfig.thinkingConfig.includeThoughts" - if tc.to == "antigravity" { + if tc.to == "gemini-cli" || tc.to == "antigravity" { path = "request.generationConfig.thinkingConfig.includeThoughts" } itVal := gjson.GetBytes(body, path) @@ -1049,6 +2775,17 @@ func TestThinkingE2EMatrix(t *testing.T) { t.Fatalf("includeThoughts: expected %s, got %s, body=%s", tc.includeThoughts, actual, string(body)) } } + + // Verify clear_thinking for iFlow GLM models when enable_thinking=true + if tc.to == "iflow" && tc.expectField == "chat_template_kwargs.enable_thinking" && tc.expectValue == "true" { + ctVal := gjson.GetBytes(body, "chat_template_kwargs.clear_thinking") + if !ctVal.Exists() { + t.Fatalf("expected clear_thinking field not found for GLM model, body=%s", string(body)) + } + if ctVal.Bool() != false { + t.Fatalf("clear_thinking: expected false, got %v, body=%s", ctVal.Bool(), string(body)) + } + } }) } }