Merge pull request #406 from router-for-me/main

v6.8.40
This commit is contained in:
Luis Pater
2026-03-03 21:51:48 +08:00
committed by GitHub
22 changed files with 1188 additions and 223 deletions

View File

@@ -192,17 +192,6 @@ func startCallbackForwarder(port int, provider, targetBase string) (*callbackFor
return forwarder, nil
}
func stopCallbackForwarder(port int) {
callbackForwardersMu.Lock()
forwarder := callbackForwarders[port]
if forwarder != nil {
delete(callbackForwarders, port)
}
callbackForwardersMu.Unlock()
stopForwarderInstance(port, forwarder)
}
func stopCallbackForwarderInstance(port int, forwarder *callbackForwarder) {
if forwarder == nil {
return
@@ -644,28 +633,66 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid name"})
return
}
full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
if !filepath.IsAbs(full) {
if abs, errAbs := filepath.Abs(full); errAbs == nil {
full = abs
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
targetID := ""
if targetAuth := h.findAuthForDelete(name); targetAuth != nil {
targetID = strings.TrimSpace(targetAuth.ID)
if path := strings.TrimSpace(authAttribute(targetAuth, "path")); path != "" {
targetPath = path
}
}
if err := os.Remove(full); err != nil {
if os.IsNotExist(err) {
if !filepath.IsAbs(targetPath) {
if abs, errAbs := filepath.Abs(targetPath); errAbs == nil {
targetPath = abs
}
}
if errRemove := os.Remove(targetPath); errRemove != nil {
if os.IsNotExist(errRemove) {
c.JSON(404, gin.H{"error": "file not found"})
} else {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", errRemove)})
}
return
}
if err := h.deleteTokenRecord(ctx, full); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
c.JSON(500, gin.H{"error": errDeleteRecord.Error()})
return
}
h.disableAuth(ctx, full)
if targetID != "" {
h.disableAuth(ctx, targetID)
} else {
h.disableAuth(ctx, targetPath)
}
c.JSON(200, gin.H{"status": "ok"})
}
func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
if h == nil || h.authManager == nil {
return nil
}
name = strings.TrimSpace(name)
if name == "" {
return nil
}
if auth, ok := h.authManager.GetByID(name); ok {
return auth
}
auths := h.authManager.List()
for _, auth := range auths {
if auth == nil {
continue
}
if strings.TrimSpace(auth.FileName) == name {
return auth
}
if filepath.Base(strings.TrimSpace(authAttribute(auth, "path"))) == name {
return auth
}
}
return nil
}
func (h *Handler) authIDForPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
@@ -899,10 +926,19 @@ func (h *Handler) disableAuth(ctx context.Context, id string) {
if h == nil || h.authManager == nil {
return
}
authID := h.authIDForPath(id)
if authID == "" {
authID = strings.TrimSpace(id)
id = strings.TrimSpace(id)
if id == "" {
return
}
if auth, ok := h.authManager.GetByID(id); ok {
auth.Disabled = true
auth.Status = coreauth.StatusDisabled
auth.StatusMessage = "removed via management API"
auth.UpdatedAt = time.Now()
_, _ = h.authManager.Update(ctx, auth)
return
}
authID := h.authIDForPath(id)
if authID == "" {
return
}

View File

@@ -0,0 +1,129 @@
package management
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) {
t.Setenv("MANAGEMENT_PASSWORD", "")
gin.SetMode(gin.TestMode)
tempDir := t.TempDir()
authDir := filepath.Join(tempDir, "auth")
externalDir := filepath.Join(tempDir, "external")
if errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil {
t.Fatalf("failed to create auth dir: %v", errMkdirAuth)
}
if errMkdirExternal := os.MkdirAll(externalDir, 0o700); errMkdirExternal != nil {
t.Fatalf("failed to create external dir: %v", errMkdirExternal)
}
fileName := "codex-user@example.com-plus.json"
shadowPath := filepath.Join(authDir, fileName)
realPath := filepath.Join(externalDir, fileName)
if errWriteShadow := os.WriteFile(shadowPath, []byte(`{"type":"codex","email":"shadow@example.com"}`), 0o600); errWriteShadow != nil {
t.Fatalf("failed to write shadow file: %v", errWriteShadow)
}
if errWriteReal := os.WriteFile(realPath, []byte(`{"type":"codex","email":"real@example.com"}`), 0o600); errWriteReal != nil {
t.Fatalf("failed to write real file: %v", errWriteReal)
}
manager := coreauth.NewManager(nil, nil, nil)
record := &coreauth.Auth{
ID: "legacy/" + fileName,
FileName: fileName,
Provider: "codex",
Status: coreauth.StatusError,
Unavailable: true,
Attributes: map[string]string{
"path": realPath,
},
Metadata: map[string]any{
"type": "codex",
"email": "real@example.com",
},
}
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
t.Fatalf("failed to register auth record: %v", errRegister)
}
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
h.tokenStore = &memoryAuthStore{}
deleteRec := httptest.NewRecorder()
deleteCtx, _ := gin.CreateTestContext(deleteRec)
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
deleteCtx.Request = deleteReq
h.DeleteAuthFile(deleteCtx)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
}
if _, errStatReal := os.Stat(realPath); !os.IsNotExist(errStatReal) {
t.Fatalf("expected managed auth file to be removed, stat err: %v", errStatReal)
}
if _, errStatShadow := os.Stat(shadowPath); errStatShadow != nil {
t.Fatalf("expected shadow auth file to remain, stat err: %v", errStatShadow)
}
listRec := httptest.NewRecorder()
listCtx, _ := gin.CreateTestContext(listRec)
listReq := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
listCtx.Request = listReq
h.ListAuthFiles(listCtx)
if listRec.Code != http.StatusOK {
t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, listRec.Code, listRec.Body.String())
}
var listPayload map[string]any
if errUnmarshal := json.Unmarshal(listRec.Body.Bytes(), &listPayload); errUnmarshal != nil {
t.Fatalf("failed to decode list payload: %v", errUnmarshal)
}
filesRaw, ok := listPayload["files"].([]any)
if !ok {
t.Fatalf("expected files array, payload: %#v", listPayload)
}
if len(filesRaw) != 0 {
t.Fatalf("expected removed auth to be hidden from list, got %d entries", len(filesRaw))
}
}
func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) {
t.Setenv("MANAGEMENT_PASSWORD", "")
gin.SetMode(gin.TestMode)
authDir := t.TempDir()
fileName := "fallback-user.json"
filePath := filepath.Join(authDir, fileName)
if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
t.Fatalf("failed to write auth file: %v", errWrite)
}
manager := coreauth.NewManager(nil, nil, nil)
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
h.tokenStore = &memoryAuthStore{}
deleteRec := httptest.NewRecorder()
deleteCtx, _ := gin.CreateTestContext(deleteRec)
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
deleteCtx.Request = deleteReq
h.DeleteAuthFile(deleteCtx)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
}
if _, errStat := os.Stat(filePath); !os.IsNotExist(errStat) {
t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat)
}
}

View File

@@ -37,7 +37,7 @@ func GetClaudeModels() []*ModelInfo {
DisplayName: "Claude 4.6 Sonnet",
ContextLength: 200000,
MaxCompletionTokens: 64000,
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high"}},
},
{
ID: "claude-opus-4-6",
@@ -49,7 +49,7 @@ func GetClaudeModels() []*ModelInfo {
Description: "Premium model combining maximum intelligence with practical performance",
ContextLength: 1000000,
MaxCompletionTokens: 128000,
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high", "max"}},
},
{
ID: "claude-sonnet-4-6",

View File

@@ -634,6 +634,12 @@ func disableThinkingIfToolChoiceForced(body []byte) []byte {
if toolChoiceType == "any" || toolChoiceType == "tool" {
// Remove thinking configuration entirely to avoid API error
body, _ = sjson.DeleteBytes(body, "thinking")
// Adaptive thinking may also set output_config.effort; remove it to avoid
// leaking thinking controls when tool_choice forces tool use.
body, _ = sjson.DeleteBytes(body, "output_config.effort")
if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
body, _ = sjson.DeleteBytes(body, "output_config")
}
}
return body
}

View File

@@ -293,7 +293,7 @@ func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat stri
if config.Mode != ModeLevel {
return config
}
if !isBudgetBasedProvider(toFormat) || !isLevelBasedProvider(fromFormat) {
if !isBudgetCapableProvider(toFormat) {
return config
}
budget, ok := ConvertLevelToBudget(string(config.Level))
@@ -353,6 +353,26 @@ func extractClaudeConfig(body []byte) ThinkingConfig {
if thinkingType == "disabled" {
return ThinkingConfig{Mode: ModeNone, Budget: 0}
}
if thinkingType == "adaptive" || thinkingType == "auto" {
// Claude adaptive thinking uses output_config.effort (low/medium/high/max).
// We only treat it as a thinking config when effort is explicitly present;
// otherwise we passthrough and let upstream defaults apply.
if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() && effort.Type == gjson.String {
value := strings.ToLower(strings.TrimSpace(effort.String()))
if value == "" {
return ThinkingConfig{}
}
switch value {
case "none":
return ThinkingConfig{Mode: ModeNone, Budget: 0}
case "auto":
return ThinkingConfig{Mode: ModeAuto, Budget: -1}
default:
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
}
}
return ThinkingConfig{}
}
// Check budget_tokens
if budget := gjson.GetBytes(body, "thinking.budget_tokens"); budget.Exists() {

View File

@@ -16,6 +16,9 @@ var levelToBudgetMap = map[string]int{
"medium": 8192,
"high": 24576,
"xhigh": 32768,
// "max" is used by Claude adaptive thinking effort. We map it to a large budget
// and rely on per-model clamping when converting to budget-only providers.
"max": 128000,
}
// ConvertLevelToBudget converts a thinking level to a budget value.
@@ -31,6 +34,7 @@ var levelToBudgetMap = map[string]int{
// - medium → 8192
// - high → 24576
// - xhigh → 32768
// - max → 128000
//
// Returns:
// - budget: The converted budget value
@@ -92,6 +96,43 @@ func ConvertBudgetToLevel(budget int) (string, bool) {
}
}
// HasLevel reports whether the given target level exists in the levels slice.
// Matching is case-insensitive with leading/trailing whitespace trimmed.
func HasLevel(levels []string, target string) bool {
for _, level := range levels {
if strings.EqualFold(strings.TrimSpace(level), target) {
return true
}
}
return false
}
// MapToClaudeEffort maps a generic thinking level string to a Claude adaptive
// thinking effort value (low/medium/high/max).
//
// supportsMax indicates whether the target model supports "max" effort.
// Returns the mapped effort and true if the level is valid, or ("", false) otherwise.
func MapToClaudeEffort(level string, supportsMax bool) (string, bool) {
level = strings.ToLower(strings.TrimSpace(level))
switch level {
case "":
return "", false
case "minimal":
return "low", true
case "low", "medium", "high":
return level, true
case "xhigh", "max":
if supportsMax {
return "max", true
}
return "high", true
case "auto":
return "high", true
default:
return "", false
}
}
// ModelCapability describes the thinking format support of a model.
type ModelCapability int

View File

@@ -1,8 +1,10 @@
// Package claude implements thinking configuration scaffolding for Claude models.
//
// Claude models use the thinking.budget_tokens format with values in the range
// 1024-128000. Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5),
// while older models do not.
// Claude models support two thinking control styles:
// - Manual thinking: thinking.type="enabled" with thinking.budget_tokens (token budget)
// - Adaptive thinking (Claude 4.6): thinking.type="adaptive" with output_config.effort (low/medium/high/max)
//
// Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5), while older models do not.
// See: _bmad-output/planning-artifacts/architecture.md#Epic-6
package claude
@@ -34,7 +36,11 @@ func init() {
// - Budget clamping to model range
// - ZeroAllowed constraint enforcement
//
// Apply only processes ModeBudget and ModeNone; other modes are passed through unchanged.
// Apply processes:
// - ModeBudget: manual thinking budget_tokens
// - ModeLevel: adaptive thinking effort (Claude 4.6)
// - ModeAuto: provider default adaptive/manual behavior
// - ModeNone: disabled
//
// Expected output format when enabled:
//
@@ -45,6 +51,17 @@ func init() {
// }
// }
//
// Expected output format for adaptive:
//
// {
// "thinking": {
// "type": "adaptive"
// },
// "output_config": {
// "effort": "high"
// }
// }
//
// Expected output format when disabled:
//
// {
@@ -60,30 +77,91 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
return body, nil
}
// Only process ModeBudget and ModeNone; other modes pass through
// (caller should use ValidateConfig first to normalize modes)
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone {
return body, nil
}
if len(body) == 0 || !gjson.ValidBytes(body) {
body = []byte(`{}`)
}
// Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced)
// Decide enabled/disabled based on budget value
if config.Budget == 0 {
supportsAdaptive := modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0
switch config.Mode {
case thinking.ModeNone:
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
result, _ = sjson.DeleteBytes(result, "output_config.effort")
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
return result, nil
case thinking.ModeLevel:
// Adaptive thinking effort is only valid when the model advertises discrete levels.
// (Claude 4.6 uses output_config.effort.)
if supportsAdaptive && config.Level != "" {
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
return result, nil
}
// Fallback for non-adaptive Claude models: convert level to budget_tokens.
if budget, ok := thinking.ConvertLevelToBudget(string(config.Level)); ok {
config.Mode = thinking.ModeBudget
config.Budget = budget
config.Level = ""
} else {
return body, nil
}
fallthrough
case thinking.ModeBudget:
// Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced).
// Decide enabled/disabled based on budget value.
if config.Budget == 0 {
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
result, _ = sjson.DeleteBytes(result, "output_config.effort")
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
return result, nil
}
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
result, _ = sjson.DeleteBytes(result, "output_config.effort")
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
// Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint).
result = a.normalizeClaudeBudget(result, config.Budget, modelInfo)
return result, nil
case thinking.ModeAuto:
// For Claude 4.6 models, auto maps to adaptive thinking with upstream defaults.
if supportsAdaptive {
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
// Explicit effort is optional for adaptive thinking; omit it to allow upstream default.
result, _ = sjson.DeleteBytes(result, "output_config.effort")
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
return result, nil
}
// Legacy fallback: enable thinking without specifying budget_tokens.
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
result, _ = sjson.DeleteBytes(result, "output_config.effort")
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
return result, nil
default:
return body, nil
}
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.
@@ -141,7 +219,7 @@ func (a *Applier) effectiveMaxTokens(body []byte, modelInfo *registry.ModelInfo)
}
func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto && config.Mode != thinking.ModeLevel {
return body, nil
}
@@ -153,14 +231,36 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte,
case thinking.ModeNone:
result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
result, _ = sjson.DeleteBytes(result, "output_config.effort")
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
return result, nil
case thinking.ModeAuto:
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
result, _ = sjson.DeleteBytes(result, "output_config.effort")
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
return result, nil
case thinking.ModeLevel:
// For user-defined models, interpret ModeLevel as Claude adaptive thinking effort.
// Upstream is responsible for validating whether the target model supports it.
if config.Level == "" {
return body, nil
}
result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
return result, nil
default:
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
result, _ = sjson.DeleteBytes(result, "output_config.effort")
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
return result, nil
}
}

View File

@@ -7,8 +7,6 @@
package codex
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/tidwall/gjson"
@@ -68,7 +66,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
effort := ""
support := modelInfo.Thinking
if config.Budget == 0 {
if support.ZeroAllowed || hasLevel(support.Levels, string(thinking.LevelNone)) {
if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) {
effort = string(thinking.LevelNone)
}
}
@@ -120,12 +118,3 @@ func applyCompatibleCodex(body []byte, config thinking.ThinkingConfig) ([]byte,
result, _ := sjson.SetBytes(body, "reasoning.effort", effort)
return result, nil
}
func hasLevel(levels []string, target string) bool {
for _, level := range levels {
if strings.EqualFold(strings.TrimSpace(level), target) {
return true
}
}
return false
}

View File

@@ -6,8 +6,6 @@
package openai
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
"github.com/tidwall/gjson"
@@ -65,7 +63,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
effort := ""
support := modelInfo.Thinking
if config.Budget == 0 {
if support.ZeroAllowed || hasLevel(support.Levels, string(thinking.LevelNone)) {
if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) {
effort = string(thinking.LevelNone)
}
}
@@ -117,12 +115,3 @@ func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte,
result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
return result, nil
}
func hasLevel(levels []string, target string) bool {
for _, level := range levels {
if strings.EqualFold(strings.TrimSpace(level), target) {
return true
}
}
return false
}

View File

@@ -30,7 +30,7 @@ func StripThinkingConfig(body []byte, provider string) []byte {
var paths []string
switch provider {
case "claude":
paths = []string{"thinking"}
paths = []string{"thinking", "output_config.effort"}
case "gemini":
paths = []string{"generationConfig.thinkingConfig"}
case "gemini-cli", "antigravity":
@@ -59,5 +59,12 @@ func StripThinkingConfig(body []byte, provider string) []byte {
for _, path := range paths {
result, _ = sjson.DeleteBytes(result, path)
}
// Avoid leaving an empty output_config object for Claude when effort was the only field.
if provider == "claude" {
if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
result, _ = sjson.DeleteBytes(result, "output_config")
}
}
return result
}

View File

@@ -109,7 +109,7 @@ func ParseSpecialSuffix(rawSuffix string) (mode ThinkingMode, ok bool) {
// ParseLevelSuffix attempts to parse a raw suffix as a discrete thinking level.
//
// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as a level.
// Only discrete effort levels are valid: minimal, low, medium, high, xhigh.
// Only discrete effort levels are valid: minimal, low, medium, high, xhigh, max.
// Level matching is case-insensitive.
//
// Special values (none, auto) are NOT handled by this function; use ParseSpecialSuffix
@@ -140,6 +140,8 @@ func ParseLevelSuffix(rawSuffix string) (level ThinkingLevel, ok bool) {
return LevelHigh, true
case "xhigh":
return LevelXHigh, true
case "max":
return LevelMax, true
default:
return "", false
}

View File

@@ -54,6 +54,9 @@ const (
LevelHigh ThinkingLevel = "high"
// LevelXHigh sets extra-high thinking effort
LevelXHigh ThinkingLevel = "xhigh"
// LevelMax sets maximum thinking effort.
// This is currently used by Claude 4.6 adaptive thinking (opus supports "max").
LevelMax ThinkingLevel = "max"
)
// ThinkingConfig represents a unified thinking configuration.

View File

@@ -53,7 +53,17 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo
return &config, nil
}
allowClampUnsupported := isBudgetBasedProvider(fromFormat) && isLevelBasedProvider(toFormat)
// allowClampUnsupported determines whether to clamp unsupported levels instead of returning an error.
// This applies when crossing provider families (e.g., openai→gemini, claude→gemini) and the target
// model supports discrete levels. Same-family conversions require strict validation.
toCapability := detectModelCapability(modelInfo)
toHasLevelSupport := toCapability == CapabilityLevelOnly || toCapability == CapabilityHybrid
allowClampUnsupported := toHasLevelSupport && !isSameProviderFamily(fromFormat, toFormat)
// strictBudget determines whether to enforce strict budget range validation.
// This applies when: (1) config comes from request body (not suffix), (2) source format is known,
// and (3) source and target are in the same provider family. Cross-family or suffix-based configs
// are clamped instead of rejected to improve interoperability.
strictBudget := !fromSuffix && fromFormat != "" && isSameProviderFamily(fromFormat, toFormat)
budgetDerivedFromLevel := false
@@ -201,7 +211,7 @@ func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupp
}
// standardLevelOrder defines the canonical ordering of thinking levels from lowest to highest.
var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh}
var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh, LevelMax}
// clampLevel clamps the given level to the nearest supported level.
// On tie, prefers the lower level.
@@ -325,7 +335,9 @@ func normalizeLevels(levels []string) []string {
return out
}
func isBudgetBasedProvider(provider string) bool {
// isBudgetCapableProvider returns true if the provider supports budget-based thinking.
// These providers may also support level-based thinking (hybrid models).
func isBudgetCapableProvider(provider string) bool {
switch provider {
case "gemini", "gemini-cli", "antigravity", "claude":
return true
@@ -334,15 +346,6 @@ func isBudgetBasedProvider(provider string) bool {
}
}
func isLevelBasedProvider(provider string) bool {
switch provider {
case "openai", "openai-response", "codex":
return true
default:
return false
}
}
func isGeminiFamily(provider string) bool {
switch provider {
case "gemini", "gemini-cli", "antigravity":
@@ -352,11 +355,21 @@ func isGeminiFamily(provider string) bool {
}
}
func isOpenAIFamily(provider string) bool {
switch provider {
case "openai", "openai-response", "codex":
return true
default:
return false
}
}
func isSameProviderFamily(from, to string) bool {
if from == to {
return true
}
return isGeminiFamily(from) && isGeminiFamily(to)
return (isGeminiFamily(from) && isGeminiFamily(to)) ||
(isOpenAIFamily(from) && isOpenAIFamily(to))
}
func abs(x int) int {

View File

@@ -14,6 +14,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"
@@ -115,24 +116,47 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
// Include thoughts configuration for reasoning process visibility
// Translator only does format conversion, ApplyThinking handles model capability validation.
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
mi := registry.LookupModelInfo(modelName, "claude")
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
// validation errors since validate treats same-provider unsupported levels as errors.
thinkingLevel := thinkingConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = thinkingConfig.Get("thinking_level")
}
if 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 {
if supportsAdaptive {
switch level {
case "":
case "none":
out, _ = sjson.Set(out, "thinking.type", "disabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Delete(out, "output_config.effort")
default:
if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {
level = mapped
}
out, _ = sjson.Set(out, "thinking.type", "adaptive")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Set(out, "output_config.effort", level)
}
} else {
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.Set(out, "thinking.budget_tokens", budget)
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 {
@@ -142,16 +166,35 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
}
if 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)
if supportsAdaptive {
switch budget {
case 0:
out, _ = sjson.Set(out, "thinking.type", "disabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Delete(out, "output_config.effort")
default:
level, ok := thinking.ConvertBudgetToLevel(budget)
if ok {
if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM {
level = mapped
}
out, _ = sjson.Set(out, "thinking.type", "adaptive")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Set(out, "output_config.effort", level)
}
}
} else {
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")

View File

@@ -14,6 +14,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/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -68,17 +69,45 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
if v := root.Get("reasoning_effort"); v.Exists() {
effort := strings.ToLower(strings.TrimSpace(v.String()))
if effort != "" {
budget, ok := thinking.ConvertLevelToBudget(effort)
if ok {
switch budget {
case 0:
mi := registry.LookupModelInfo(modelName, "claude")
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
// Claude 4.6 supports adaptive thinking with output_config.effort.
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
// validation errors since validate treats same-provider unsupported levels as errors.
if supportsAdaptive {
switch effort {
case "none":
out, _ = sjson.Set(out, "thinking.type", "disabled")
case -1:
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Delete(out, "output_config.effort")
case "auto":
out, _ = sjson.Set(out, "thinking.type", "adaptive")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Delete(out, "output_config.effort")
default:
if budget > 0 {
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
effort = mapped
}
out, _ = sjson.Set(out, "thinking.type", "adaptive")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Set(out, "output_config.effort", effort)
}
} else {
// Legacy/manual thinking (budget_tokens).
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")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
default:
if budget > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
}
}
}

View File

@@ -9,6 +9,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/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -56,17 +57,45 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
if v := root.Get("reasoning.effort"); v.Exists() {
effort := strings.ToLower(strings.TrimSpace(v.String()))
if effort != "" {
budget, ok := thinking.ConvertLevelToBudget(effort)
if ok {
switch budget {
case 0:
mi := registry.LookupModelInfo(modelName, "claude")
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
// Claude 4.6 supports adaptive thinking with output_config.effort.
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
// validation errors since validate treats same-provider unsupported levels as errors.
if supportsAdaptive {
switch effort {
case "none":
out, _ = sjson.Set(out, "thinking.type", "disabled")
case -1:
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Delete(out, "output_config.effort")
case "auto":
out, _ = sjson.Set(out, "thinking.type", "adaptive")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Delete(out, "output_config.effort")
default:
if budget > 0 {
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
effort = mapped
}
out, _ = sjson.Set(out, "thinking.type", "adaptive")
out, _ = sjson.Delete(out, "thinking.budget_tokens")
out, _ = sjson.Set(out, "output_config.effort", effort)
}
} else {
// Legacy/manual thinking (budget_tokens).
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")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
default:
if budget > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
}
}
}

View File

@@ -160,7 +160,51 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
flushMessage()
functionCallOutputMessage := `{"type":"function_call_output"}`
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "call_id", messageContentResult.Get("tool_use_id").String())
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
contentResult := messageContentResult.Get("content")
if contentResult.IsArray() {
toolResultContentIndex := 0
toolResultContent := `[]`
contentResults := contentResult.Array()
for k := 0; k < len(contentResults); k++ {
toolResultContentType := contentResults[k].Get("type").String()
if toolResultContentType == "image" {
sourceResult := contentResults[k].Get("source")
if sourceResult.Exists() {
data := sourceResult.Get("data").String()
if data == "" {
data = sourceResult.Get("base64").String()
}
if data != "" {
mediaType := sourceResult.Get("media_type").String()
if mediaType == "" {
mediaType = sourceResult.Get("mime_type").String()
}
if mediaType == "" {
mediaType = "application/octet-stream"
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.type", toolResultContentIndex), "input_image")
toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.image_url", toolResultContentIndex), dataURL)
toolResultContentIndex++
}
}
} else if toolResultContentType == "text" {
toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.type", toolResultContentIndex), "input_text")
toolResultContent, _ = sjson.Set(toolResultContent, fmt.Sprintf("%d.text", toolResultContentIndex), contentResults[k].Get("text").String())
toolResultContentIndex++
}
}
if toolResultContent != `[]` {
functionCallOutputMessage, _ = sjson.SetRaw(functionCallOutputMessage, "output", toolResultContent)
} else {
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
}
} else {
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
}
template, _ = sjson.SetRaw(template, "input.-1", functionCallOutputMessage)
}
}
@@ -231,9 +275,17 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
}
}
case "adaptive", "auto":
// Claude adaptive/auto means "enable with max capacity"; keep it as highest level
// and let ApplyThinking normalize per target model capability.
reasoningEffort = string(thinking.LevelXHigh)
// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).
// Pass through directly; ApplyThinking handles clamping to target model's levels.
effort := ""
if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
reasoningEffort = effort
} else {
reasoningEffort = string(thinking.LevelXHigh)
}
case "disabled":
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
reasoningEffort = effort

View File

@@ -171,7 +171,8 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
}
}
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
// Map Anthropic thinking -> Gemini CLI thinkingConfig when enabled
// Translator only does format conversion, ApplyThinking handles model capability validation.
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
switch t.Get("type").String() {
case "enabled":
@@ -181,9 +182,19 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
}
case "adaptive", "auto":
// Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it
// to model-specific max capability.
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
// For adaptive thinking:
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
// - Otherwise, treat it as "enabled with target-model maximum" and emit high.
// ApplyThinking handles clamping to target model's supported levels.
effort := ""
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort)
} else {
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
}
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
}
}

View File

@@ -9,6 +9,7 @@ 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"
@@ -151,7 +152,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
}
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
// Map Anthropic thinking -> Gemini thinking config when enabled
// Translator only does format conversion, ApplyThinking handles model capability validation.
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
switch t.Get("type").String() {
@@ -162,9 +163,27 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true)
}
case "adaptive", "auto":
// Keep adaptive/auto as a high level sentinel; ApplyThinking resolves it
// to model-specific max capability.
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
// For adaptive thinking:
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
// - Otherwise, treat it as "enabled with target-model maximum" and emit thinkingBudget=max.
// ApplyThinking handles clamping to target model's supported levels.
effort := ""
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", effort)
} else {
maxBudget := 0
if mi := registry.LookupModelInfo(modelName, "gemini"); mi != nil && mi.Thinking != nil {
maxBudget = mi.Thinking.Max
}
if maxBudget > 0 {
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", maxBudget)
} else {
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
}
}
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true)
}
}

View File

@@ -76,9 +76,17 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
}
case "adaptive", "auto":
// Claude adaptive/auto means "enable with max capacity"; keep it as highest level
// and let ApplyThinking normalize per target model capability.
out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh))
// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).
// Pass through directly; ApplyThinking handles clamping to target model's levels.
effort := ""
if v := root.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
} else {
out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh))
}
case "disabled":
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)

View File

@@ -7,6 +7,8 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
@@ -115,8 +117,19 @@ func newCredentialRetryLimitTestManager(t *testing.T, maxRetryCredentials int) (
executor := &credentialRetryLimitExecutor{id: "claude"}
m.RegisterExecutor(executor)
auth1 := &Auth{ID: "auth-1", Provider: "claude"}
auth2 := &Auth{ID: "auth-2", Provider: "claude"}
baseID := uuid.NewString()
auth1 := &Auth{ID: baseID + "-auth-1", Provider: "claude"}
auth2 := &Auth{ID: baseID + "-auth-2", Provider: "claude"}
// Auth selection requires that the global model registry knows each credential supports the model.
reg := registry.GetGlobalRegistry()
reg.RegisterClient(auth1.ID, "claude", []*registry.ModelInfo{{ID: "test-model"}})
reg.RegisterClient(auth2.ID, "claude", []*registry.ModelInfo{{ID: "test-model"}})
t.Cleanup(func() {
reg.UnregisterClient(auth1.ID)
reg.UnregisterClient(auth2.ID)
})
if _, errRegister := m.Register(context.Background(), auth1); errRegister != nil {
t.Fatalf("register auth1: %v", errRegister)
}

View File

@@ -34,6 +34,8 @@ type thinkingTestCase struct {
inputJSON string
expectField string
expectValue string
expectField2 string
expectValue2 string
includeThoughts string
expectErr bool
}
@@ -384,15 +386,17 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
// Case 30: Effort xhigh → not in low/high → error
// Case 30: Effort xhigh → clamped to high
{
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,
name: "30",
from: "openai",
to: "gemini",
model: "gemini-mixed-model(xhigh)",
inputJSON: `{"model":"gemini-mixed-model(xhigh)","messages":[{"role":"user","content":"hi"}]}`,
expectField: "generationConfig.thinkingConfig.thinkingLevel",
expectValue: "high",
includeThoughts: "true",
expectErr: false,
},
// Case 31: Effort none → clamped to low (min supported) → includeThoughts=false
{
@@ -1782,15 +1786,17 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
// Case 30: reasoning_effort=xhigh → error (not in low/high)
// Case 30: reasoning_effort=xhigh → clamped to 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,
name: "30",
from: "openai",
to: "gemini",
model: "gemini-mixed-model",
inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
expectField: "generationConfig.thinkingConfig.thinkingLevel",
expectValue: "high",
includeThoughts: "true",
expectErr: false,
},
// Case 31: reasoning_effort=none → clamped to low → includeThoughts=false
{
@@ -2822,9 +2828,8 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
runThinkingTests(t, cases)
}
// TestThinkingE2EClaudeAdaptive_Body tests Claude thinking.type=adaptive extended body-only cases.
// These cases validate that adaptive means "thinking enabled without explicit budget", and
// cross-protocol conversion should resolve to target-model maximum thinking capability.
// TestThinkingE2EClaudeAdaptive_Body covers Group 3 cases in docs/thinking-e2e-test-cases.md.
// It focuses on Claude 4.6 adaptive thinking and effort/level cross-protocol semantics (body-only).
func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
reg := registry.GetGlobalRegistry()
uid := fmt.Sprintf("thinking-e2e-claude-adaptive-%d", time.Now().UnixNano())
@@ -2833,32 +2838,347 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
defer reg.UnregisterClient(uid)
cases := []thinkingTestCase{
// A1: Claude adaptive to OpenAI level model -> highest supported level
// A subgroup: OpenAI -> Claude (reasoning_effort -> output_config.effort)
{
name: "A1",
from: "openai",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"minimal"}`,
expectField: "output_config.effort",
expectValue: "low",
expectErr: false,
},
{
name: "A2",
from: "openai",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"low"}`,
expectField: "output_config.effort",
expectValue: "low",
expectErr: false,
},
{
name: "A3",
from: "openai",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"medium"}`,
expectField: "output_config.effort",
expectValue: "medium",
expectErr: false,
},
{
name: "A4",
from: "openai",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"}`,
expectField: "output_config.effort",
expectValue: "high",
expectErr: false,
},
{
name: "A5",
from: "openai",
to: "claude",
model: "claude-opus-4-6-model",
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
expectField: "output_config.effort",
expectValue: "max",
expectErr: false,
},
{
name: "A6",
from: "openai",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"}`,
expectField: "output_config.effort",
expectValue: "high",
expectErr: false,
},
{
name: "A7",
from: "openai",
to: "claude",
model: "claude-opus-4-6-model",
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"max"}`,
expectField: "output_config.effort",
expectValue: "max",
expectErr: false,
},
{
name: "A8",
from: "openai",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"max"}`,
expectField: "output_config.effort",
expectValue: "high",
expectErr: false,
},
// B subgroup: Gemini -> Claude (thinkingLevel/thinkingBudget -> output_config.effort)
{
name: "B1",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"minimal"}}}`,
expectField: "output_config.effort",
expectValue: "low",
expectErr: false,
},
{
name: "B2",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"low"}}}`,
expectField: "output_config.effort",
expectValue: "low",
expectErr: false,
},
{
name: "B3",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"}}}`,
expectField: "output_config.effort",
expectValue: "medium",
expectErr: false,
},
{
name: "B4",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`,
expectField: "output_config.effort",
expectValue: "high",
expectErr: false,
},
{
name: "B5",
from: "gemini",
to: "claude",
model: "claude-opus-4-6-model",
inputJSON: `{"model":"claude-opus-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`,
expectField: "output_config.effort",
expectValue: "max",
expectErr: false,
},
{
name: "B6",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingLevel":"xhigh"}}}`,
expectField: "output_config.effort",
expectValue: "high",
expectErr: false,
},
{
name: "B7",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":512}}}`,
expectField: "output_config.effort",
expectValue: "low",
expectErr: false,
},
{
name: "B8",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":1024}}}`,
expectField: "output_config.effort",
expectValue: "low",
expectErr: false,
},
{
name: "B9",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}`,
expectField: "output_config.effort",
expectValue: "medium",
expectErr: false,
},
{
name: "B10",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":24576}}}`,
expectField: "output_config.effort",
expectValue: "high",
expectErr: false,
},
{
name: "B11",
from: "gemini",
to: "claude",
model: "claude-opus-4-6-model",
inputJSON: `{"model":"claude-opus-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":32768}}}`,
expectField: "output_config.effort",
expectValue: "max",
expectErr: false,
},
{
name: "B12",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":32768}}}`,
expectField: "output_config.effort",
expectValue: "high",
expectErr: false,
},
{
name: "B13",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`,
expectField: "thinking.type",
expectValue: "disabled",
expectErr: false,
},
{
name: "B14",
from: "gemini",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`,
expectField: "output_config.effort",
expectValue: "high",
expectErr: false,
},
// C subgroup: Claude adaptive + effort cross-protocol conversion
{
name: "C1",
from: "claude",
to: "openai",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`,
expectField: "reasoning_effort",
expectValue: "minimal",
expectErr: false,
},
{
name: "C2",
from: "claude",
to: "openai",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`,
expectField: "reasoning_effort",
expectValue: "low",
expectErr: false,
},
{
name: "C3",
from: "claude",
to: "openai",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`,
expectField: "reasoning_effort",
expectValue: "medium",
expectErr: false,
},
{
name: "C4",
from: "claude",
to: "openai",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectField: "reasoning_effort",
expectValue: "high",
expectErr: false,
},
// A2: Claude adaptive to Gemini level subset model -> highest supported level
{
name: "A2",
name: "C5",
from: "claude",
to: "openai",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
expectField: "reasoning_effort",
expectValue: "high",
expectErr: false,
},
{
name: "C6",
from: "claude",
to: "openai",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
expectField: "reasoning_effort",
expectValue: "high",
expectErr: false,
},
{
name: "C7",
from: "claude",
to: "openai",
model: "no-thinking-model",
inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectField: "",
expectErr: false,
},
{
name: "C8",
from: "claude",
to: "gemini",
model: "level-subset-model",
inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectField: "generationConfig.thinkingConfig.thinkingLevel",
expectValue: "high",
includeThoughts: "true",
expectErr: false,
},
// A3: Claude adaptive to Gemini budget model -> max budget
{
name: "A3",
name: "C9",
from: "claude",
to: "gemini",
model: "gemini-budget-model",
inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`,
expectField: "generationConfig.thinkingConfig.thinkingBudget",
expectValue: "1024",
includeThoughts: "true",
expectErr: false,
},
{
name: "C10",
from: "claude",
to: "gemini",
model: "gemini-budget-model",
inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`,
expectField: "generationConfig.thinkingConfig.thinkingBudget",
expectValue: "8192",
includeThoughts: "true",
expectErr: false,
},
{
name: "C11",
from: "claude",
to: "gemini",
model: "gemini-budget-model",
inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectField: "generationConfig.thinkingConfig.thinkingBudget",
expectValue: "20000",
includeThoughts: "true",
expectErr: false,
},
{
name: "C12",
from: "claude",
to: "gemini",
model: "gemini-budget-model",
@@ -2868,32 +3188,91 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
// A4: Claude adaptive to Gemini mixed model -> highest supported level
{
name: "A4",
name: "C13",
from: "claude",
to: "gemini",
model: "gemini-mixed-model",
inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectField: "generationConfig.thinkingConfig.thinkingLevel",
expectValue: "high",
includeThoughts: "true",
expectErr: false,
},
// A5: Claude adaptive passthrough for same protocol
{
name: "A5",
name: "C14",
from: "claude",
to: "claude",
model: "claude-budget-model",
inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "thinking.type",
expectValue: "adaptive",
to: "codex",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`,
expectField: "reasoning.effort",
expectValue: "minimal",
expectErr: false,
},
// A6: Claude adaptive to Antigravity budget model -> max budget
{
name: "A6",
name: "C15",
from: "claude",
to: "codex",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`,
expectField: "reasoning.effort",
expectValue: "low",
expectErr: false,
},
{
name: "C16",
from: "claude",
to: "codex",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectField: "reasoning.effort",
expectValue: "high",
expectErr: false,
},
{
name: "C17",
from: "claude",
to: "codex",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
expectField: "reasoning.effort",
expectValue: "high",
expectErr: false,
},
{
name: "C18",
from: "claude",
to: "codex",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
expectField: "reasoning.effort",
expectValue: "high",
expectErr: false,
},
{
name: "C19",
from: "claude",
to: "iflow",
model: "glm-test",
inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"minimal"}}`,
expectField: "chat_template_kwargs.enable_thinking",
expectValue: "true",
expectErr: false,
},
{
name: "C20",
from: "claude",
to: "iflow",
model: "minimax-test",
inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectField: "reasoning_split",
expectValue: "true",
expectErr: false,
},
{
name: "C21",
from: "claude",
to: "antigravity",
model: "antigravity-budget-model",
@@ -2903,48 +3282,66 @@ func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
// A7: Claude adaptive to iFlow GLM -> enabled boolean
{
name: "A7",
from: "claude",
to: "iflow",
model: "glm-test",
inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "chat_template_kwargs.enable_thinking",
expectValue: "true",
expectErr: false,
name: "C22",
from: "claude",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`,
expectField: "thinking.type",
expectValue: "adaptive",
expectField2: "output_config.effort",
expectValue2: "medium",
expectErr: false,
},
// A8: Claude adaptive to iFlow MiniMax -> enabled boolean
{
name: "A8",
from: "claude",
to: "iflow",
model: "minimax-test",
inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "reasoning_split",
expectValue: "true",
expectErr: false,
name: "C23",
from: "claude",
to: "claude",
model: "claude-opus-4-6-model",
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
expectField: "thinking.type",
expectValue: "adaptive",
expectField2: "output_config.effort",
expectValue2: "max",
expectErr: false,
},
// A9: Claude adaptive to Codex level model -> highest supported level
{
name: "A9",
from: "claude",
to: "codex",
model: "level-model",
inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "reasoning.effort",
expectValue: "high",
expectErr: false,
name: "C24",
from: "claude",
to: "claude",
model: "claude-opus-4-6-model",
inputJSON: `{"model":"claude-opus-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
expectErr: true,
},
// A10: Claude adaptive on non-thinking model should still be stripped
{
name: "A10",
from: "claude",
to: "openai",
model: "no-thinking-model",
inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`,
expectField: "",
expectErr: false,
name: "C25",
from: "claude",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectField: "thinking.type",
expectValue: "adaptive",
expectField2: "output_config.effort",
expectValue2: "high",
expectErr: false,
},
{
name: "C26",
from: "claude",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`,
expectErr: true,
},
{
name: "C27",
from: "claude",
to: "claude",
model: "claude-sonnet-4-6-model",
inputJSON: `{"model":"claude-sonnet-4-6-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}`,
expectErr: true,
},
}
@@ -2999,6 +3396,29 @@ func getTestModels() []*registry.ModelInfo {
DisplayName: "Claude Budget Model",
Thinking: &registry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false},
},
{
ID: "claude-sonnet-4-6-model",
Object: "model",
Created: 1771372800, // 2026-02-17
OwnedBy: "anthropic",
Type: "claude",
DisplayName: "Claude 4.6 Sonnet",
ContextLength: 200000,
MaxCompletionTokens: 64000,
Thinking: &registry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high"}},
},
{
ID: "claude-opus-4-6-model",
Object: "model",
Created: 1770318000, // 2026-02-05
OwnedBy: "anthropic",
Type: "claude",
DisplayName: "Claude 4.6 Opus",
Description: "Premium model combining maximum intelligence with practical performance",
ContextLength: 1000000,
MaxCompletionTokens: 128000,
Thinking: &registry.ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: false, Levels: []string{"low", "medium", "high", "max"}},
},
{
ID: "antigravity-budget-model",
Object: "model",
@@ -3165,17 +3585,23 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
return
}
val := gjson.GetBytes(body, tc.expectField)
if !val.Exists() {
t.Fatalf("expected field %s not found, body=%s", tc.expectField, string(body))
assertField := func(fieldPath, expected string) {
val := gjson.GetBytes(body, fieldPath)
if !val.Exists() {
t.Fatalf("expected field %s not found, body=%s", fieldPath, string(body))
}
actualValue := val.String()
if val.Type == gjson.Number {
actualValue = fmt.Sprintf("%d", val.Int())
}
if actualValue != expected {
t.Fatalf("field %s: expected %q, got %q, body=%s", fieldPath, expected, actualValue, string(body))
}
}
actualValue := val.String()
if val.Type == gjson.Number {
actualValue = fmt.Sprintf("%d", val.Int())
}
if actualValue != tc.expectValue {
t.Fatalf("field %s: expected %q, got %q, body=%s", tc.expectField, tc.expectValue, actualValue, string(body))
assertField(tc.expectField, tc.expectValue)
if tc.expectField2 != "" {
assertField(tc.expectField2, tc.expectValue2)
}
if tc.includeThoughts != "" && (tc.to == "gemini" || tc.to == "gemini-cli" || tc.to == "antigravity") {