From f8f3ad84fcaf33443746e6c0d2eba420220046bb Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 17 Jan 2026 05:40:56 +0800 Subject: [PATCH 01/22] Fixed: #1064 feat(translator): improve system message handling and content indexing across translators - Updated logic for processing system messages in `claude`, `gemini`, `gemini-cli`, and `antigravity` translators. - Introduced indexing for `systemInstruction.parts` to ensure proper ordering and handling of multi-part content. - Added safeguards for accurate content transformation and serialization. --- .../antigravity_openai_request.go | 10 +++++-- .../chat-completions/claude_openai_request.go | 30 +++++++++++++++---- .../gemini-cli_openai_request.go | 10 +++++-- .../chat-completions/gemini_openai_request.go | 12 +++++--- 4 files changed, 47 insertions(+), 15 deletions(-) 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..89e486c0 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++ } } } 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..8aa14793 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -141,17 +141,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 +248,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 +259,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/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/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index fedd8dca..27805dd8 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++ } } } From 109cffc010d8f6fbec8d0ee3c456c11502f15873 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:20:58 +0800 Subject: [PATCH 02/22] refactor(auth): simplify filename prefixes for qwen and iflow tokens --- internal/api/handlers/management/auth_files.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 27c9a902..e6830d1d 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1703,7 +1703,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", @@ -1808,7 +1808,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{ @@ -1893,15 +1893,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, From 8549a92e9a2e47b3353d36ada7018bb02b46a31b Mon Sep 17 00:00:00 2001 From: Tubagus <54710482+0xtbug@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:29:22 +0700 Subject: [PATCH 03/22] docs(readme): add ZeroLimit to projects based on CLIProxyAPI Added ZeroLimit app to the list of projects in README. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7875a989..07d47905 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ Windows-native CLIProxyAPI fork with TUI, system tray, and multi-provider OAuth VSCode extension for quick switching between Claude Code models, featuring integrated CLIProxyAPI as its backend with automatic background lifecycle management. +### [ZeroLimit](https://github.com/0xtbug/zero-limit) + +Windows desktop app built with Tauri + React for monitoring AI coding assistant quotas. Track usage across Gemini, Claude, OpenAI Codex, and Antigravity accounts with real-time dashboard, system tray integration, and one-click proxy control - no API keys needed. + > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. From dbba71028ec35debe89a991ba031571d442fc903 Mon Sep 17 00:00:00 2001 From: Tubagus <54710482+0xtbug@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:30:15 +0700 Subject: [PATCH 04/22] docs(readme): add ZeroLimit to projects based on CLIProxyAPI --- README_CN.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README_CN.md b/README_CN.md index fdc8d64c..8600aada 100644 --- a/README_CN.md +++ b/README_CN.md @@ -129,6 +129,10 @@ CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户 一款 VSCode 扩展,提供了在 VSCode 中快速切换 Claude Code 模型的功能,内置 CLIProxyAPI 作为其后端,支持后台自动启动和关闭。 +### [ZeroLimit](https://github.com/0xtbug/zero-limit) + +Windows 桌面应用,基于 Tauri + React 构建,用于监控 AI 编程助手配额。支持跨 Gemini、Claude、OpenAI Codex 和 Antigravity 账户的使用量追踪,提供实时仪表盘、系统托盘集成和一键代理控制,无需 API 密钥。 + > [!NOTE] > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。 From f89feb881c01ab250fc44f493651fb58b312622d Mon Sep 17 00:00:00 2001 From: Tubagus <54710482+0xtbug@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:33:18 +0700 Subject: [PATCH 05/22] Update README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 07d47905..bd339982 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ VSCode extension for quick switching between Claude Code models, featuring integ ### [ZeroLimit](https://github.com/0xtbug/zero-limit) -Windows desktop app built with Tauri + React for monitoring AI coding assistant quotas. Track usage across Gemini, Claude, OpenAI Codex, and Antigravity accounts with real-time dashboard, system tray integration, and one-click proxy control - no API keys needed. +Windows desktop app built with Tauri + React for monitoring AI coding assistant quotas via CLIProxyAPI. Track usage across Gemini, Claude, OpenAI Codex, and Antigravity accounts with real-time dashboard, system tray integration, and one-click proxy control - no API keys needed. > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. From c8843edb81bb975e642abac84cb54e04af281b3b Mon Sep 17 00:00:00 2001 From: Tubagus <54710482+0xtbug@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:33:29 +0700 Subject: [PATCH 06/22] Update README_CN.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README_CN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_CN.md b/README_CN.md index 8600aada..1b3ed74b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -131,7 +131,7 @@ CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户 ### [ZeroLimit](https://github.com/0xtbug/zero-limit) -Windows 桌面应用,基于 Tauri + React 构建,用于监控 AI 编程助手配额。支持跨 Gemini、Claude、OpenAI Codex 和 Antigravity 账户的使用量追踪,提供实时仪表盘、系统托盘集成和一键代理控制,无需 API 密钥。 +Windows 桌面应用,基于 Tauri + React 构建,用于通过 CLIProxyAPI 监控 AI 编程助手配额。支持跨 Gemini、Claude、OpenAI Codex 和 Antigravity 账户的使用量追踪,提供实时仪表盘、系统托盘集成和一键代理控制,无需 API 密钥。 > [!NOTE] > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。 From b4e070697de3535a6f0ad0f30e664ead9999f6c7 Mon Sep 17 00:00:00 2001 From: clstb Date: Sat, 17 Jan 2026 17:20:55 +0100 Subject: [PATCH 07/22] feat: support github copilot in management ui --- .../api/handlers/management/auth_files.go | 84 +++++++++++++++++++ .../api/handlers/management/oauth_sessions.go | 2 + internal/api/server.go | 1 + sdk/cliproxy/auth/types.go | 12 +++ 4 files changed, 99 insertions(+) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 010ed084..1b238768 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" @@ -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() 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 4df42ec8..2beb1d94 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -643,6 +643,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/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 { From 46433a25f8f75a0b538624262bc6dc959e77994d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 18 Jan 2026 00:50:10 +0800 Subject: [PATCH 08/22] fix(translator): add check for empty `text` to prevent invalid serialization in `gemini` and `antigravity` --- .../chat-completions/antigravity_openai_request.go | 10 ++++++++-- .../openai/chat-completions/gemini_openai_request.go | 9 ++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) 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 89e486c0..94546bda 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -169,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() @@ -213,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/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index 27805dd8..7ad005b9 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -166,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() @@ -211,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. From dd6d78cb31b1dc268510c5bffeb1e0f960a1817c Mon Sep 17 00:00:00 2001 From: sususu98 Date: Sat, 17 Jan 2026 17:50:10 +0800 Subject: [PATCH 09/22] fix(antigravity): convert non-string enum values to strings for Gemini API Gemini API requires all enum values in function declarations to be strings. Some MCP tools (e.g., roxybrowser) define schemas with numeric enums like `"enum": [0, 1, 2]`, causing INVALID_ARGUMENT errors. Add convertEnumValuesToStrings() to automatically convert numeric and boolean enum values to their string representations during schema transformation. --- internal/util/gemini_schema.go | 28 ++++++++++++++++ internal/util/gemini_schema_test.go | 51 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) 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) + } +} From 97b67e0e4991cf45c777385400c3319d3f156ee5 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:01:30 +0800 Subject: [PATCH 10/22] test(thinking): split E2E coverage into suffix and body parameter test functions Refactor thinking configuration tests by separating model name suffix-based scenarios from request body parameter-based scenarios into distinct test functions with independent case numbering. Architectural improvements: - Extract thinkingTestCase struct to package level for shared usage - Add getTestModels() helper returning complete model fixture set - Introduce runThinkingTests() runner with protocol-specific field detection - Register level-subset-model fixture with constrained low/high level support - Extend iflow protocol handling for glm-test and minimax-test models - Add same-protocol strict boundary validation cases (80-89) - Replace error responses with clamped values for boundary-exceeding budgets --- test/thinking_conversion_test.go | 3368 ++++++++++++++++++++++-------- 1 file changed, 2476 insertions(+), 892 deletions(-) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index f28aa630..91490fa2 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -22,15 +22,2425 @@ import ( "github.com/tidwall/gjson" ) -// 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: Codex to Gemini budget 8192 → passthrough → 8192 + { + name: "78", + from: "codex", + 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: Codex to Claude budget 8192 → passthrough → 8192 + { + name: "79", + from: "codex", + 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: Codex to Codex, level high → passthrough reasoning.effort + { + name: "82", + from: "codex", + 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: Codex to Codex, level xhigh → out of range error + { + name: "83", + from: "codex", + 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 → exceeds Max error + { + name: "85", + from: "gemini", + to: "gemini", + model: "gemini-budget-model(64000)", + inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: true, + }, + // 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 → exceeds Max error + { + name: "87", + from: "claude", + to: "claude", + model: "claude-budget-model(200000)", + inputJSON: `{"model":"claude-budget-model(200000)","messages":[{"role":"user","content":"hi"}]}`, + expectField: "", + expectErr: true, + }, + // Case 88: Antigravity to Antigravity, budget 8192 → passthrough thinkingBudget + { + name: "88", + from: "antigravity", + 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: Antigravity to Antigravity, budget 64000 → exceeds Max error + { + name: "89", + from: "antigravity", + to: "antigravity", + model: "antigravity-budget-model(64000)", + inputJSON: `{"model":"antigravity-budget-model(64000)","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, + expectField: "", + expectErr: true, + }, + + // 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, + }, + } + + 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: Codex reasoning.effort=medium to Gemini → 8192 + { + name: "78", + from: "codex", + 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: Codex reasoning.effort=medium to Claude → 8192 + { + name: "79", + from: "codex", + 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: Codex to Codex, reasoning.effort=high → passthrough + { + name: "82", + from: "codex", + 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: Codex to Codex, reasoning.effort=xhigh → out of range error + { + name: "83", + from: "codex", + 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: Antigravity to Antigravity, thinkingBudget=8192 → passthrough + { + name: "88", + from: "antigravity", + 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: Antigravity to Antigravity, thinkingBudget=64000 → exceeds Max error + { + name: "89", + from: "antigravity", + 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, + }, + } + + 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 +2448,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 +2466,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 +2475,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 +2484,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 +2493,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 +2514,53 @@ 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, ) - // 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 +2571,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 +2595,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,7 +2608,6 @@ 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") { path := "generationConfig.thinkingConfig.includeThoughts" if tc.to == "antigravity" { @@ -1049,6 +2622,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)) + } + } }) } } From d5ef4a6d1571d9ebb2208b8605f5e71704c70c0e Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:25:56 +0800 Subject: [PATCH 11/22] refactor(translator): remove registry model lookups from thinking config conversions --- .../claude/antigravity_claude_request.go | 14 +++--- .../claude/antigravity_claude_request_test.go | 4 +- .../claude/gemini/claude_gemini_request.go | 43 ++++++++++++++----- .../chat-completions/claude_openai_request.go | 29 ++++++------- .../claude_openai-responses_request.go | 29 ++++++------- .../codex/claude/codex_claude_request.go | 16 +++---- .../codex/gemini/codex_gemini_request.go | 27 +++++++----- .../claude/gemini-cli_claude_request.go | 14 +++--- .../gemini/claude/gemini_claude_request.go | 16 +++---- .../openai/gemini/openai_gemini_request.go | 13 +++--- 10 files changed, 107 insertions(+), 98 deletions(-) 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/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 8aa14793..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) } } } 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/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/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/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) } } From c7e8830a563d2615753162381fc2d0937c5ce0aa Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:53:10 +0800 Subject: [PATCH 12/22] refactor(thinking): pass source and target formats to ApplyThinking for cross-format validation Update ApplyThinking signature to accept fromFormat and toFormat parameters instead of a single provider string. This enables: - Proper level-to-budget conversion when source is level-based (openai/codex) and target is budget-based (gemini/claude) - Strict budget range validation when source and target formats match - Level clamping to nearest supported level for cross-format requests - Format alias resolution in SDK translator registry for codex/openai-response Also adds ErrBudgetOutOfRange error code and improves iflow config extraction to fall back to openai format when iflow-specific config is not present. --- .../runtime/executor/aistudio_executor.go | 2 +- .../runtime/executor/antigravity_executor.go | 8 +- internal/runtime/executor/claude_executor.go | 4 +- internal/runtime/executor/codex_executor.go | 6 +- .../runtime/executor/gemini_cli_executor.go | 6 +- internal/runtime/executor/gemini_executor.go | 6 +- .../executor/gemini_vertex_executor.go | 12 +- internal/runtime/executor/iflow_executor.go | 4 +- .../executor/openai_compat_executor.go | 6 +- internal/runtime/executor/qwen_executor.go | 4 +- internal/thinking/apply.go | 86 ++++-- internal/thinking/errors.go | 4 + internal/thinking/strip.go | 32 ++- internal/thinking/validate.go | 270 ++++++++++++------ sdk/translator/registry.go | 62 +++- 15 files changed, 341 insertions(+), 171 deletions(-) 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..99392188 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 } diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index b4cbd450..17c5a143 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -106,7 +106,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), stream) body, _ = 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 } @@ -239,7 +239,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) body, _ = 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 } 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..fe7d59b4 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) 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/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..853e187d 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,14 @@ 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) // // 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 // // Returns: // - Normalized ThinkingConfig with clamped values @@ -87,9 +33,9 @@ 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) { +func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFormat, toFormat string) (*ThinkingConfig, error) { + fromFormat, toFormat = strings.ToLower(strings.TrimSpace(fromFormat)), strings.ToLower(strings.TrimSpace(toFormat)) normalized := config - model := "unknown" support := (*registry.ThinkingSupport)(nil) if modelInfo != nil { @@ -106,6 +52,9 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provid return &normalized, nil } + allowClampUnsupported := isBudgetBasedProvider(fromFormat) && isLevelBasedProvider(toFormat) + strictBudget := fromFormat != "" && fromFormat == toFormat + capability := detectModelCapability(modelInfo) switch capability { case CapabilityBudgetOnly: @@ -127,8 +76,10 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provid if !ok { return nil, NewThinkingError(ErrUnknownLevel, fmt.Sprintf("budget %d cannot be converted to a valid level", normalized.Budget)) } + // When converting Budget -> Level for level-only models, clamp the derived standard level + // to the nearest supported level. Special values (none/auto) are preserved. normalized.Mode = ModeLevel - normalized.Level = ThinkingLevel(level) + normalized.Level = clampLevel(ThinkingLevel(level), modelInfo, toFormat) normalized.Budget = 0 } case CapabilityHybrid: @@ -151,18 +102,35 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provid 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 allowClampUnsupported { + normalized.Level = clampLevel(normalized.Level, modelInfo, toFormat) + } + if !isLevelSupported(string(normalized.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(normalized.Level)), strings.Join(validLevels, ", ")) + return nil, NewThinkingError(ErrLevelNotSupported, message) + } + } + } + + if strictBudget && normalized.Mode == ModeBudget { + min, max := support.Min, support.Max + if min != 0 || max != 0 { + if normalized.Budget < min || normalized.Budget > max || (normalized.Budget == 0 && !support.ZeroAllowed) { + message := fmt.Sprintf("budget %d out of range [%d,%d]", normalized.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) + normalized = convertAutoToMidRange(normalized, support, toFormat, model) } - if normalized.Mode == ModeNone && provider == "claude" { + if normalized.Mode == ModeNone && toFormat == "claude" { // Claude supports explicit disable via thinking.type="disabled". // Keep Budget=0 so applier can omit budget_tokens. normalized.Budget = 0 @@ -170,7 +138,7 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provid } else { switch normalized.Mode { case ModeBudget, ModeAuto, ModeNone: - normalized.Budget = ClampBudget(normalized.Budget, modelInfo, provider) + normalized.Budget = clampBudget(normalized.Budget, modelInfo, toFormat) } // ModeNone with clamped Budget > 0: set Level to lowest for Level-only/Hybrid models @@ -183,23 +151,6 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, provid 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 -} - // convertAutoToMidRange converts ModeAuto to a mid-range value when dynamic is not allowed. // // This function handles the case where a model does not support dynamic/auto thinking. @@ -246,7 +197,156 @@ 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_level": string(level), + "clamped_to": string(clamped), + }).Debug("thinking: level clamped |") + return clamped + } + return level +} + +// clampBudget clamps a budget value to the model's supported range. +func clampBudget(value int, modelInfo *registry.ModelInfo, provider string) int { + model := "unknown" + support := (*registry.ThinkingSupport)(nil) + if modelInfo != nil { + if modelInfo.ID != "" { + model = modelInfo.ID + } + support = modelInfo.Thinking + } + if support == nil { + return value + } + + // Auto value (-1) passes through without clamping. + if value == -1 { + return value + } + + min, max := support.Min, support.Max + if value == 0 && !support.ZeroAllowed { + log.WithFields(log.Fields{ + "provider": provider, + "model": model, + "original_value": value, + "clamped_to": min, + "min": min, + "max": max, + }).Warn("thinking: budget zero not allowed |") + return min + } + + // Some models are level-only and do not define numeric budget ranges. + if min == 0 && max == 0 { + return value + } + + if value < min { + if value == 0 && support.ZeroAllowed { + return 0 + } + logClamp(provider, model, value, min, min, max) + return min + } + if value > max { + logClamp(provider, model, value, max, min, max) + return max + } + return value +} + +func isLevelSupported(level string, supported []string) bool { + for _, s := range supported { + if strings.EqualFold(level, strings.TrimSpace(s)) { + return true + } + } + return false +} + +func levelIndex(level string) int { + for i, l := range standardLevelOrder { + if strings.EqualFold(level, string(l)) { + return i + } + } + return -1 +} + +func normalizeLevels(levels []string) []string { + out := make([]string, len(levels)) + for i, l := range levels { + out[i] = strings.ToLower(strings.TrimSpace(l)) + } + return out +} + +func isBudgetBasedProvider(provider string) bool { + switch provider { + case "gemini", "gemini-cli", "antigravity", "claude": + return true + default: + return false + } +} + +func isLevelBasedProvider(provider string) bool { + switch provider { + case "openai", "openai-response", "codex": + return true + default: + return false + } +} + +func 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/sdk/translator/registry.go b/sdk/translator/registry.go index ace97137..882e80f6 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -38,15 +38,31 @@ func (r *Registry) Register(from, to Format, request RequestTransform, response r.responses[from][to] = response } +// formatAliases returns compatible aliases for a format, ordered by preference. +func formatAliases(format Format) []Format { + switch format { + case "codex": + return []Format{"codex", "openai-response"} + case "openai-response": + return []Format{"openai-response", "codex"} + default: + return []Format{format} + } +} + // TranslateRequest converts a payload between schemas, returning the original payload // if no translator is registered. func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte { r.mu.RLock() defer r.mu.RUnlock() - if byTarget, ok := r.requests[from]; ok { - if fn, isOk := byTarget[to]; isOk && fn != nil { - return fn(model, rawJSON, stream) + for _, fromFormat := range formatAliases(from) { + if byTarget, ok := r.requests[fromFormat]; ok { + for _, toFormat := range formatAliases(to) { + if fn, isOk := byTarget[toFormat]; isOk && fn != nil { + return fn(model, rawJSON, stream) + } + } } } return rawJSON @@ -57,9 +73,13 @@ func (r *Registry) HasResponseTransformer(from, to Format) bool { r.mu.RLock() defer r.mu.RUnlock() - if byTarget, ok := r.responses[from]; ok { - if _, isOk := byTarget[to]; isOk { - return true + for _, toFormat := range formatAliases(to) { + if byTarget, ok := r.responses[toFormat]; ok { + for _, fromFormat := range formatAliases(from) { + if _, isOk := byTarget[fromFormat]; isOk { + return true + } + } } } return false @@ -70,9 +90,13 @@ func (r *Registry) TranslateStream(ctx context.Context, from, to Format, model s r.mu.RLock() defer r.mu.RUnlock() - if byTarget, ok := r.responses[to]; ok { - if fn, isOk := byTarget[from]; isOk && fn.Stream != nil { - return fn.Stream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) + for _, toFormat := range formatAliases(to) { + if byTarget, ok := r.responses[toFormat]; ok { + for _, fromFormat := range formatAliases(from) { + if fn, isOk := byTarget[fromFormat]; isOk && fn.Stream != nil { + return fn.Stream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) + } + } } } return []string{string(rawJSON)} @@ -83,9 +107,13 @@ func (r *Registry) TranslateNonStream(ctx context.Context, from, to Format, mode r.mu.RLock() defer r.mu.RUnlock() - if byTarget, ok := r.responses[to]; ok { - if fn, isOk := byTarget[from]; isOk && fn.NonStream != nil { - return fn.NonStream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) + for _, toFormat := range formatAliases(to) { + if byTarget, ok := r.responses[toFormat]; ok { + for _, fromFormat := range formatAliases(from) { + if fn, isOk := byTarget[fromFormat]; isOk && fn.NonStream != nil { + return fn.NonStream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) + } + } } } return string(rawJSON) @@ -96,9 +124,13 @@ func (r *Registry) TranslateTokenCount(ctx context.Context, from, to Format, cou r.mu.RLock() defer r.mu.RUnlock() - if byTarget, ok := r.responses[to]; ok { - if fn, isOk := byTarget[from]; isOk && fn.TokenCount != nil { - return fn.TokenCount(ctx, count) + for _, toFormat := range formatAliases(to) { + if byTarget, ok := r.responses[toFormat]; ok { + for _, fromFormat := range formatAliases(from) { + if fn, isOk := byTarget[fromFormat]; isOk && fn.TokenCount != nil { + return fn.TokenCount(ctx, count) + } + } } } return string(rawJSON) From 03005b5d299b62fc1ed7eee55506c5a687243948 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 18 Jan 2026 11:30:53 +0800 Subject: [PATCH 13/22] refactor(thinking): add Gemini family provider grouping for strict validation --- internal/logging/global_logger.go | 2 +- internal/thinking/validate.go | 107 +++++++++++-------- sdk/translator/registry.go | 62 +++-------- test/thinking_conversion_test.go | 168 +++++++++++++++++++++++++++--- 4 files changed, 230 insertions(+), 109 deletions(-) diff --git a/internal/logging/global_logger.go b/internal/logging/global_logger.go index 63c7af46..a5630964 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_value", "original_level", "min", "max", "clamped_to", "error"} // Format renders a single log entry with custom formatting. func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) { diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 853e187d..5ce113f7 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -35,7 +35,6 @@ import ( // - Hybrid model → preserve original format func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFormat, toFormat string) (*ThinkingConfig, error) { fromFormat, toFormat = strings.ToLower(strings.TrimSpace(fromFormat)), strings.ToLower(strings.TrimSpace(toFormat)) - normalized := config model := "unknown" support := (*registry.ThinkingSupport)(nil) if modelInfo != nil { @@ -49,106 +48,108 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo 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 := fromFormat != "" && fromFormat == toFormat + strictBudget := 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)) } // When converting Budget -> Level for level-only models, clamp the derived standard level // to the nearest supported level. Special values (none/auto) are preserved. - normalized.Mode = ModeLevel - normalized.Level = clampLevel(ThinkingLevel(level), modelInfo, toFormat) - normalized.Budget = 0 + 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) { + if len(support.Levels) > 0 && config.Mode == ModeLevel { + if !isLevelSupported(string(config.Level), support.Levels) { if allowClampUnsupported { - normalized.Level = clampLevel(normalized.Level, modelInfo, toFormat) + config.Level = clampLevel(config.Level, modelInfo, toFormat) } - if !isLevelSupported(string(normalized.Level), support.Levels) { + 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(normalized.Level)), strings.Join(validLevels, ", ")) + 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 && normalized.Mode == ModeBudget { + if strictBudget && config.Mode == ModeBudget && !budgetDerivedFromLevel { min, max := support.Min, support.Max if min != 0 || max != 0 { - if normalized.Budget < min || normalized.Budget > max || (normalized.Budget == 0 && !support.ZeroAllowed) { - message := fmt.Sprintf("budget %d out of range [%d,%d]", normalized.Budget, min, max) + 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, toFormat, model) + if config.Mode == ModeAuto && !support.DynamicAllowed { + config = convertAutoToMidRange(config, support, toFormat, model) } - if normalized.Mode == ModeNone && toFormat == "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, toFormat) + 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 + return &config, nil } // convertAutoToMidRange converts ModeAuto to a mid-range value when dynamic is not allowed. @@ -340,6 +341,22 @@ func isLevelBasedProvider(provider string) bool { } } +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 diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index 882e80f6..ace97137 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -38,31 +38,15 @@ func (r *Registry) Register(from, to Format, request RequestTransform, response r.responses[from][to] = response } -// formatAliases returns compatible aliases for a format, ordered by preference. -func formatAliases(format Format) []Format { - switch format { - case "codex": - return []Format{"codex", "openai-response"} - case "openai-response": - return []Format{"openai-response", "codex"} - default: - return []Format{format} - } -} - // TranslateRequest converts a payload between schemas, returning the original payload // if no translator is registered. func (r *Registry) TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte { r.mu.RLock() defer r.mu.RUnlock() - for _, fromFormat := range formatAliases(from) { - if byTarget, ok := r.requests[fromFormat]; ok { - for _, toFormat := range formatAliases(to) { - if fn, isOk := byTarget[toFormat]; isOk && fn != nil { - return fn(model, rawJSON, stream) - } - } + if byTarget, ok := r.requests[from]; ok { + if fn, isOk := byTarget[to]; isOk && fn != nil { + return fn(model, rawJSON, stream) } } return rawJSON @@ -73,13 +57,9 @@ func (r *Registry) HasResponseTransformer(from, to Format) bool { r.mu.RLock() defer r.mu.RUnlock() - for _, toFormat := range formatAliases(to) { - if byTarget, ok := r.responses[toFormat]; ok { - for _, fromFormat := range formatAliases(from) { - if _, isOk := byTarget[fromFormat]; isOk { - return true - } - } + if byTarget, ok := r.responses[from]; ok { + if _, isOk := byTarget[to]; isOk { + return true } } return false @@ -90,13 +70,9 @@ func (r *Registry) TranslateStream(ctx context.Context, from, to Format, model s r.mu.RLock() defer r.mu.RUnlock() - for _, toFormat := range formatAliases(to) { - if byTarget, ok := r.responses[toFormat]; ok { - for _, fromFormat := range formatAliases(from) { - if fn, isOk := byTarget[fromFormat]; isOk && fn.Stream != nil { - return fn.Stream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) - } - } + if byTarget, ok := r.responses[to]; ok { + if fn, isOk := byTarget[from]; isOk && fn.Stream != nil { + return fn.Stream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) } } return []string{string(rawJSON)} @@ -107,13 +83,9 @@ func (r *Registry) TranslateNonStream(ctx context.Context, from, to Format, mode r.mu.RLock() defer r.mu.RUnlock() - for _, toFormat := range formatAliases(to) { - if byTarget, ok := r.responses[toFormat]; ok { - for _, fromFormat := range formatAliases(from) { - if fn, isOk := byTarget[fromFormat]; isOk && fn.NonStream != nil { - return fn.NonStream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) - } - } + if byTarget, ok := r.responses[to]; ok { + if fn, isOk := byTarget[from]; isOk && fn.NonStream != nil { + return fn.NonStream(ctx, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) } } return string(rawJSON) @@ -124,13 +96,9 @@ func (r *Registry) TranslateTokenCount(ctx context.Context, from, to Format, cou r.mu.RLock() defer r.mu.RUnlock() - for _, toFormat := range formatAliases(to) { - if byTarget, ok := r.responses[toFormat]; ok { - for _, fromFormat := range formatAliases(from) { - if fn, isOk := byTarget[fromFormat]; isOk && fn.TokenCount != nil { - return fn.TokenCount(ctx, count) - } - } + if byTarget, ok := r.responses[to]; ok { + if fn, isOk := byTarget[from]; isOk && fn.TokenCount != nil { + return fn.TokenCount(ctx, count) } } return string(rawJSON) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 91490fa2..397bbbff 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -921,10 +921,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectValue: "8192", expectErr: false, }, - // Case 78: Codex to Gemini budget 8192 → passthrough → 8192 + // Case 78: OpenAI-Response to Gemini budget 8192 → passthrough → 8192 { name: "78", - from: "codex", + from: "openai-response", to: "gemini", model: "user-defined-model(8192)", inputJSON: `{"model":"user-defined-model(8192)","input":[{"role":"user","content":"hi"}]}`, @@ -933,10 +933,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 79: Codex to Claude budget 8192 → passthrough → 8192 + // Case 79: OpenAI-Response to Claude budget 8192 → passthrough → 8192 { name: "79", - from: "codex", + from: "openai-response", to: "claude", model: "user-defined-model(8192)", inputJSON: `{"model":"user-defined-model(8192)","input":[{"role":"user","content":"hi"}]}`, @@ -968,10 +968,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectField: "", expectErr: true, }, - // Case 82: Codex to Codex, level high → passthrough reasoning.effort + // Case 82: OpenAI-Response to Codex, level high → passthrough reasoning.effort { name: "82", - from: "codex", + from: "openai-response", to: "codex", model: "level-model(high)", inputJSON: `{"model":"level-model(high)","input":[{"role":"user","content":"hi"}]}`, @@ -979,10 +979,10 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectValue: "high", expectErr: false, }, - // Case 83: Codex to Codex, level xhigh → out of range error + // Case 83: OpenAI-Response to Codex, level xhigh → out of range error { name: "83", - from: "codex", + from: "openai-response", to: "codex", model: "level-model(xhigh)", inputJSON: `{"model":"level-model(xhigh)","input":[{"role":"user","content":"hi"}]}`, @@ -1232,6 +1232,74 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { 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 → exceeds Max error (same family strict validation) + { + name: "106", + from: "gemini", + to: "antigravity", + model: "gemini-budget-model(64000)", + inputJSON: `{"model":"gemini-budget-model(64000)","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + expectField: "", + expectErr: true, + }, + // Case 107: Gemini to Gemini-CLI, budget 64000 → exceeds Max error (same family strict validation) + { + 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: "", + expectErr: true, + }, + // Case 108: Gemini-CLI to Antigravity, budget 64000 → exceeds Max error (same family strict validation) + { + 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: "", + expectErr: true, + }, + // Case 109: Gemini-CLI to Gemini, budget 64000 → exceeds Max error (same family strict validation) + { + 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: "", + expectErr: true, + }, + // 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) @@ -2122,10 +2190,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectValue: "8192", expectErr: false, }, - // Case 78: Codex reasoning.effort=medium to Gemini → 8192 + // Case 78: OpenAI-Response reasoning.effort=medium to Gemini → 8192 { name: "78", - from: "codex", + from: "openai-response", to: "gemini", model: "user-defined-model", inputJSON: `{"model":"user-defined-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"medium"}}`, @@ -2134,10 +2202,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 79: Codex reasoning.effort=medium to Claude → 8192 + // Case 79: OpenAI-Response reasoning.effort=medium to Claude → 8192 { name: "79", - from: "codex", + from: "openai-response", to: "claude", model: "user-defined-model", inputJSON: `{"model":"user-defined-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"medium"}}`, @@ -2169,10 +2237,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 82: Codex to Codex, reasoning.effort=high → passthrough + // Case 82: OpenAI-Response to Codex, reasoning.effort=high → passthrough { name: "82", - from: "codex", + from: "openai-response", to: "codex", model: "level-model", inputJSON: `{"model":"level-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"high"}}`, @@ -2180,10 +2248,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectValue: "high", expectErr: false, }, - // Case 83: Codex to Codex, reasoning.effort=xhigh → out of range error + // Case 83: OpenAI-Response to Codex, reasoning.effort=xhigh → out of range error { name: "83", - from: "codex", + from: "openai-response", to: "codex", model: "level-model", inputJSON: `{"model":"level-model","input":[{"role":"user","content":"hi"}],"reasoning":{"effort":"xhigh"}}`, @@ -2433,6 +2501,74 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { 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) From 62e2b672d9ef20eddc61f35ef19ecefbd57d29e0 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 18 Jan 2026 12:40:57 +0800 Subject: [PATCH 14/22] refactor(logging): centralize log directory resolution logic - Introduced `ResolveLogDirectory` function in `logging` package to standardize log directory determination across components. - Replaced redundant logic in `server`, `global_logger`, and `handlers` with the new utility function. --- internal/api/handlers/management/logs.go | 13 ++---------- internal/api/server.go | 5 +---- internal/logging/global_logger.go | 25 ++++++++++++++++++------ 3 files changed, 22 insertions(+), 21 deletions(-) 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/server.go b/internal/api/server.go index 831bf003..aa78ac2a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -261,10 +261,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 diff --git a/internal/logging/global_logger.go b/internal/logging/global_logger.go index 63c7af46..746bce28 100644 --- a/internal/logging/global_logger.go +++ b/internal/logging/global_logger.go @@ -121,6 +121,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. @@ -130,12 +148,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 { From cb6caf3f872128a439f773adab99e3d26fd1b64a Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:37:14 +0800 Subject: [PATCH 15/22] fix(thinking): update ValidateConfig to include fromSuffix parameter and adjust budget validation logic --- internal/logging/global_logger.go | 2 +- internal/thinking/apply.go | 2 +- internal/thinking/validate.go | 8 +- test/thinking_conversion_test.go | 131 ++++++++++++++++-------------- 4 files changed, 79 insertions(+), 64 deletions(-) diff --git a/internal/logging/global_logger.go b/internal/logging/global_logger.go index 146f6c80..28c9f3b9 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", "original_level", "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) { diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index fe7d59b4..cf0e373b 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -159,7 +159,7 @@ func ApplyThinking(body []byte, model string, fromFormat string, toFormat string } // 5. Validate and normalize configuration - validated, err := ValidateConfig(config, modelInfo, fromFormat, providerFormat) + validated, err := ValidateConfig(config, modelInfo, fromFormat, providerFormat, suffixResult.HasSuffix) if err != nil { log.WithFields(log.Fields{ "provider": providerFormat, diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 5ce113f7..f082ad56 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -18,12 +18,14 @@ import ( // - 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 @@ -33,7 +35,7 @@ import ( // - Budget-only model + Level config → Level converted to Budget // - Level-only model + Budget config → Budget converted to Level // - Hybrid model → preserve original format -func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFormat, toFormat string) (*ThinkingConfig, error) { +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) @@ -52,7 +54,7 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo } allowClampUnsupported := isBudgetBasedProvider(fromFormat) && isLevelBasedProvider(toFormat) - strictBudget := fromFormat != "" && isSameProviderFamily(fromFormat, toFormat) + strictBudget := !fromSuffix && fromFormat != "" && isSameProviderFamily(fromFormat, toFormat) budgetDerivedFromLevel := false capability := detectModelCapability(modelInfo) @@ -238,7 +240,7 @@ func clampLevel(level ThinkingLevel, modelInfo *registry.ModelInfo, provider str log.WithFields(log.Fields{ "provider": provider, "model": model, - "original_level": string(level), + "original_value": string(level), "clamped_to": string(clamped), }).Debug("thinking: level clamped |") return clamped diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 397bbbff..8f527193 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -1001,15 +1001,17 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 85: Gemini to Gemini, budget 64000 → exceeds Max error + // 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: "", - expectErr: true, + 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 { @@ -1022,20 +1024,21 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { expectValue: "8192", expectErr: false, }, - // Case 87: Claude to Claude, budget 200000 → exceeds Max error + // 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: "", - expectErr: true, + expectField: "thinking.budget_tokens", + expectValue: "128000", + expectErr: false, }, - // Case 88: Antigravity to Antigravity, budget 8192 → passthrough thinkingBudget + // Case 88: Gemini-CLI to Antigravity, budget 8192 → passthrough thinkingBudget { name: "88", - from: "antigravity", + from: "gemini-cli", to: "antigravity", model: "antigravity-budget-model(8192)", inputJSON: `{"model":"antigravity-budget-model(8192)","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, @@ -1044,15 +1047,17 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 89: Antigravity to Antigravity, budget 64000 → exceeds Max error + // Case 89: Gemini-CLI to Antigravity, budget 64000 → clamped to Max { - name: "89", - from: "antigravity", - to: "antigravity", - model: "antigravity-budget-model(64000)", - inputJSON: `{"model":"antigravity-budget-model(64000)","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`, - expectField: "", - expectErr: true, + 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) @@ -1236,45 +1241,53 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) { // 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 → exceeds Max error (same family strict validation) + // 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: "", - expectErr: true, + 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 → exceeds Max error (same family strict validation) + // 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: "", - expectErr: true, + 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 → exceeds Max error (same family strict validation) + // 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: "", - expectErr: true, + 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 → exceeds Max error (same family strict validation) + // 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: "", - expectErr: true, + 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) { @@ -2301,10 +2314,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { expectField: "", expectErr: true, }, - // Case 88: Antigravity to Antigravity, thinkingBudget=8192 → passthrough + // Case 88: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough { name: "88", - from: "antigravity", + 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}}}}`, @@ -2313,10 +2326,10 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { includeThoughts: "true", expectErr: false, }, - // Case 89: Antigravity to Antigravity, thinkingBudget=64000 → exceeds Max error + // Case 89: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error { name: "89", - from: "antigravity", + 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}}}}`, @@ -2744,9 +2757,9 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { t.Fatalf("field %s: expected %q, got %q, body=%s", tc.expectField, tc.expectValue, actualValue, string(body)) } - 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) From 140d6211cc0a1c2e15527c96bf4158b43c1182e7 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 19 Jan 2026 03:58:28 +0800 Subject: [PATCH 16/22] feat(translator): add reasoning state tracking and improve reasoning summary handling - Introduced `oaiToResponsesStateReasoning` to track reasoning data. - Enhanced logic for emitting reasoning summary events and managing state transitions. - Updated output generation to handle multiple reasoning entries consistently. --- .../openai_openai-responses_response.go | 64 ++++++++++++++----- 1 file changed, 48 insertions(+), 16 deletions(-) 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 { From cf9daf470ca7698c08970f6cb22aede8af2677c5 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:23:44 +0800 Subject: [PATCH 17/22] feat(translator): report cached token usage in Claude output --- .../codex/claude/codex_claude_response.go | 39 +++++++++++--- .../openai/claude/openai_claude_response.go | 53 +++++++++++++------ 2 files changed, 67 insertions(+), 25 deletions(-) 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/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 +} From 52e46ced1bd33a3fd8ff8f7cd136216b734bd8fc Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:33:27 +0800 Subject: [PATCH 18/22] fix(translator): avoid forcing RFC 8259 system prompt --- internal/translator/openai/claude/openai_claude_request.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() != "" { From c421d653e75e3eb161d6f1d96578c40510e1fbb8 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:50:35 +0800 Subject: [PATCH 19/22] refactor(claude): move max_tokens constraint enforcement to Apply method --- internal/runtime/executor/claude_executor.go | 82 -------------------- internal/thinking/provider/claude/apply.go | 45 +++++++++++ 2 files changed, 45 insertions(+), 82 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 17c5a143..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" @@ -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) @@ -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/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index b7833072..babc2f76 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -80,9 +80,54 @@ 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 + } + + effectiveMax, setDefaultMax := a.effectiveMaxTokens(body, modelInfo) + if effectiveMax > 0 && effectiveMax > budgetTokens { + if setDefaultMax { + body, _ = sjson.SetBytes(body, "max_tokens", effectiveMax) + } + return body + } + + // Fall back to budget + buffer if no effective max or max <= budget + const fallbackBuffer = 4000 + requiredMaxTokens := budgetTokens + fallbackBuffer + if effectiveMax > 0 && effectiveMax > requiredMaxTokens { + requiredMaxTokens = effectiveMax + } + + currentMax := gjson.GetBytes(body, "max_tokens").Int() + if currentMax < int64(requiredMaxTokens) { + body, _ = sjson.SetBytes(body, "max_tokens", requiredMaxTokens) + } + 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 From 239a28793c3b0229a0cefe7673c4e72c54c3288e Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:32:20 +0800 Subject: [PATCH 20/22] feat(claude): clamp thinking budget to max_tokens constraints --- internal/thinking/provider/claude/apply.go | 38 ++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go index babc2f76..3c74d514 100644 --- a/internal/thinking/provider/claude/apply.go +++ b/internal/thinking/provider/claude/apply.go @@ -93,25 +93,37 @@ func (a *Applier) normalizeClaudeBudget(body []byte, budgetTokens int, modelInfo 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 effectiveMax > 0 && effectiveMax > budgetTokens { - if setDefaultMax { - body, _ = sjson.SetBytes(body, "max_tokens", effectiveMax) - } + 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 } - // Fall back to budget + buffer if no effective max or max <= budget - const fallbackBuffer = 4000 - requiredMaxTokens := budgetTokens + fallbackBuffer - if effectiveMax > 0 && effectiveMax > requiredMaxTokens { - requiredMaxTokens = effectiveMax + if adjustedBudget != budgetTokens { + body, _ = sjson.SetBytes(body, "thinking.budget_tokens", adjustedBudget) } - currentMax := gjson.GetBytes(body, "max_tokens").Int() - if currentMax < int64(requiredMaxTokens) { - body, _ = sjson.SetBytes(body, "max_tokens", requiredMaxTokens) - } return body } From c175821cc4a3960e633f6de1e880e43f79309e75 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:07:22 +0800 Subject: [PATCH 21/22] feat(registry): expand antigravity model config Remove static Name mapping and add entries for claude-sonnet-4-5, tab_flash_lite_preview, and gpt-oss-120b-medium configs --- internal/registry/model_definitions.go | 19 ++++++++++--------- .../runtime/executor/antigravity_executor.go | 3 --- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 77669e4b..080c2726 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/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 99392188..602ed628 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -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, From 1d2fe55310024844ff12aba79b5b2b21f14b7b45 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:49:39 +0800 Subject: [PATCH 22/22] fix(executor): stop rewriting thinkingLevel for gemini --- internal/runtime/executor/antigravity_executor.go | 7 ------- test/thinking_conversion_test.go | 4 ++++ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 602ed628..df26e376 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1407,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/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 8f527193..4a7df29a 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -20,6 +20,7 @@ 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" ) // thinkingTestCase represents a common test case structure for both suffix and body tests. @@ -2707,6 +2708,9 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) { []byte(tc.inputJSON), true, ) + if applyTo == "claude" { + body, _ = sjson.SetBytes(body, "max_tokens", 200000) + } body, err := thinking.ApplyThinking(body, tc.model, tc.from, applyTo)