798 lines · 30.1 KB · Go
RawDownload
package test import (	"fmt"	"strings"	"testing"	"time" 	_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" 	"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"	"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"	"github.com/router-for-me/CLIProxyAPI/v6/internal/util"	sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"	"github.com/tidwall/gjson"	"github.com/tidwall/sjson") // isOpenAICompatModel returns true if the model is configured as an OpenAI-compatible// model that should have reasoning effort passed through even if not in registry.// This simulates the allowCompat behavior from OpenAICompatExecutor.func isOpenAICompatModel(model string) bool {	return model == "openai-compat"} // registerCoreModels loads representative models across providers into the registry// so NormalizeThinkingBudget and level validation use real ranges.func registerCoreModels(t *testing.T) func() {	t.Helper()	reg := registry.GetGlobalRegistry()	uid := fmt.Sprintf("thinking-core-%d", time.Now().UnixNano())	reg.RegisterClient(uid+"-gemini", "gemini", registry.GetGeminiModels())	reg.RegisterClient(uid+"-claude", "claude", registry.GetClaudeModels())	reg.RegisterClient(uid+"-openai", "codex", registry.GetOpenAIModels())	reg.RegisterClient(uid+"-qwen", "qwen", registry.GetQwenModels())	// Custom openai-compatible model with forced thinking suffix passthrough.	// No Thinking field - simulates an external model added via openai-compat	// where the registry has no knowledge of its thinking capabilities.	// The allowCompat flag should preserve reasoning effort for such models.	customOpenAIModels := []*registry.ModelInfo{		{			ID:          "openai-compat",			Object:      "model",			Created:     1700000000,			OwnedBy:     "custom-provider",			Type:        "openai",			DisplayName: "OpenAI Compatible Model",			Description: "OpenAI-compatible model with forced thinking suffix support",		},	}	reg.RegisterClient(uid+"-custom-openai", "codex", customOpenAIModels)	return func() {		reg.UnregisterClient(uid + "-gemini")		reg.UnregisterClient(uid + "-claude")		reg.UnregisterClient(uid + "-openai")		reg.UnregisterClient(uid + "-qwen")		reg.UnregisterClient(uid + "-custom-openai")	}} var (	thinkingTestModels = []string{		"gpt-5",           // level-based thinking model		"gemini-2.5-pro",  // numeric-budget thinking model		"qwen3-code-plus", // no thinking support		"openai-compat",   // allowCompat=true (OpenAI-compatible channel)	}	thinkingTestFromProtocols = []string{"openai", "claude", "gemini", "openai-response"}	thinkingTestToProtocols   = []string{"gemini", "claude", "openai", "codex"} 	// Numeric budgets and their level equivalents:	// -1 -> auto	// 0 -> none	// 1..1024 -> low	// 1025..8192 -> medium	// 8193..24576 -> high	// >24576 -> model highest level (right-most in Levels)	thinkingNumericSamples = []int{-1, 0, 1023, 1025, 8193, 64000} 	// Levels and their numeric equivalents:	// auto -> -1	// none -> 0	// minimal -> 512	// low -> 1024	// medium -> 8192	// high -> 24576	// xhigh -> 32768	// invalid -> invalid (no mapping)	thinkingLevelSamples = []string{"auto", "none", "minimal", "low", "medium", "high", "xhigh", "invalid"}) func buildRawPayload(fromProtocol, modelWithSuffix string) []byte {	switch fromProtocol {	case "gemini":		return []byte(fmt.Sprintf(`{"model":"%s","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, modelWithSuffix))	case "openai-response":		return []byte(fmt.Sprintf(`{"model":"%s","input":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`, modelWithSuffix))	default: // openai / claude and other chat-style payloads		return []byte(fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, modelWithSuffix))	}} // normalizeCodexPayload mirrors codex_executor's reasoning + streaming tweaks.func normalizeCodexPayload(body []byte, upstreamModel string, allowCompat bool) ([]byte, error) {	body = executor.NormalizeThinkingConfig(body, upstreamModel, allowCompat)	if err := executor.ValidateThinkingConfig(body, upstreamModel); err != nil {		return body, err	}	body, _ = sjson.SetBytes(body, "model", upstreamModel)	body, _ = sjson.SetBytes(body, "stream", true)	body, _ = sjson.DeleteBytes(body, "previous_response_id")	return body, nil} // buildBodyForProtocol runs a minimal request through the same translation and// thinking pipeline used in executors for the given target protocol.func buildBodyForProtocol(t *testing.T, fromProtocol, toProtocol, modelWithSuffix string) ([]byte, error) {	t.Helper()	normalizedModel, metadata := util.NormalizeThinkingModel(modelWithSuffix)	upstreamModel := util.ResolveOriginalModel(normalizedModel, metadata)	raw := buildRawPayload(fromProtocol, modelWithSuffix)	stream := fromProtocol != toProtocol 	body := sdktranslator.TranslateRequest(		sdktranslator.FromString(fromProtocol),		sdktranslator.FromString(toProtocol),		normalizedModel,		raw,		stream,	) 	var err error	allowCompat := isOpenAICompatModel(normalizedModel)	switch toProtocol {	case "gemini":		body = executor.ApplyThinkingMetadata(body, metadata, normalizedModel)		body = util.ApplyDefaultThinkingIfNeeded(normalizedModel, body)		body = util.NormalizeGeminiThinkingBudget(normalizedModel, body)		body = util.StripThinkingConfigIfUnsupported(normalizedModel, body)	case "claude":		if budget, ok := util.ResolveClaudeThinkingConfig(normalizedModel, metadata); ok {			body = util.ApplyClaudeThinkingConfig(body, budget)		}	case "openai":		body = executor.ApplyReasoningEffortMetadata(body, metadata, normalizedModel, "reasoning_effort", allowCompat)		body = executor.NormalizeThinkingConfig(body, upstreamModel, allowCompat)		err = executor.ValidateThinkingConfig(body, upstreamModel)	case "codex": // OpenAI responses / codex		// Codex does not support allowCompat; always use false.		body = executor.ApplyReasoningEffortMetadata(body, metadata, normalizedModel, "reasoning.effort", false)		// Mirror CodexExecutor final normalization and model override so tests log the final body.		body, err = normalizeCodexPayload(body, upstreamModel, false)	default:	} 	// Mirror executor behavior: final payload uses the upstream (base) model name.	if upstreamModel != "" {		body, _ = sjson.SetBytes(body, "model", upstreamModel)	} 	// For tests we only keep model + thinking-related fields to avoid noise.	body = filterThinkingBody(toProtocol, body, upstreamModel, normalizedModel)	return body, err} // filterThinkingBody projects the translated payload down to only model and// thinking-related fields for the given target protocol.func filterThinkingBody(toProtocol string, body []byte, upstreamModel, normalizedModel string) []byte {	if len(body) == 0 {		return body	}	out := []byte(`{}`) 	// Preserve model if present, otherwise fall back to upstream/normalized model.	if m := gjson.GetBytes(body, "model"); m.Exists() {		out, _ = sjson.SetBytes(out, "model", m.Value())	} else if upstreamModel != "" {		out, _ = sjson.SetBytes(out, "model", upstreamModel)	} else if normalizedModel != "" {		out, _ = sjson.SetBytes(out, "model", normalizedModel)	} 	switch toProtocol {	case "gemini":		if tc := gjson.GetBytes(body, "generationConfig.thinkingConfig"); tc.Exists() {			out, _ = sjson.SetRawBytes(out, "generationConfig.thinkingConfig", []byte(tc.Raw))		}	case "claude":		if tcfg := gjson.GetBytes(body, "thinking"); tcfg.Exists() {			out, _ = sjson.SetRawBytes(out, "thinking", []byte(tcfg.Raw))		}	case "openai":		if re := gjson.GetBytes(body, "reasoning_effort"); re.Exists() {			out, _ = sjson.SetBytes(out, "reasoning_effort", re.Value())		}	case "codex":		if re := gjson.GetBytes(body, "reasoning.effort"); re.Exists() {			out, _ = sjson.SetBytes(out, "reasoning.effort", re.Value())		}	}	return out} func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) {	cleanup := registerCoreModels(t)	defer cleanup() 	type scenario struct {		name        string		modelSuffix string	} 	numericName := func(budget int) string {		if budget < 0 {			return "numeric-neg1"		}		return fmt.Sprintf("numeric-%d", budget)	} 	for _, model := range thinkingTestModels {		_ = registry.GetGlobalRegistry().GetModelInfo(model) 		for _, from := range thinkingTestFromProtocols {			// Scenario selection follows protocol semantics:			// - OpenAI-style protocols (openai/openai-response) express thinking as levels.			// - Claude/Gemini-style protocols express thinking as numeric budgets.			cases := []scenario{				{name: "no-suffix", modelSuffix: model},			}			if from == "openai" || from == "openai-response" {				for _, lvl := range thinkingLevelSamples {					cases = append(cases, scenario{						name:        "level-" + lvl,						modelSuffix: fmt.Sprintf("%s(%s)", model, lvl),					})				}			} else { // claude or gemini				for _, budget := range thinkingNumericSamples {					budget := budget					cases = append(cases, scenario{						name:        numericName(budget),						modelSuffix: fmt.Sprintf("%s(%d)", model, budget),					})				}			} 			for _, to := range thinkingTestToProtocols {				if from == to {					continue				}				t.Logf("─────────────────────────────────────────────────────────────────────────────────")				t.Logf("  %s -> %s | model: %s", from, to, model)				t.Logf("─────────────────────────────────────────────────────────────────────────────────")				for _, cs := range cases {					from := from					to := to					cs := cs					testName := fmt.Sprintf("%s->%s/%s/%s", from, to, model, cs.name)					t.Run(testName, func(t *testing.T) {						normalizedModel, metadata := util.NormalizeThinkingModel(cs.modelSuffix)						expectPresent, expectValue, expectErr := func() (bool, string, bool) {							switch to {							case "gemini":								budget, include, ok := util.ResolveThinkingConfigFromMetadata(normalizedModel, metadata)								if !ok || !util.ModelSupportsThinking(normalizedModel) {									return false, "", false								}								if include != nil && !*include {									return false, "", false								}								if budget == nil {									return false, "", false								}								norm := util.NormalizeThinkingBudget(normalizedModel, *budget)								return true, fmt.Sprintf("%d", norm), false							case "claude":								if !util.ModelSupportsThinking(normalizedModel) {									return false, "", false								}								budget, ok := util.ResolveClaudeThinkingConfig(normalizedModel, metadata)								if !ok || budget == nil {									return false, "", false								}								return true, fmt.Sprintf("%d", *budget), false							case "openai":								allowCompat := isOpenAICompatModel(normalizedModel)								if !util.ModelSupportsThinking(normalizedModel) && !allowCompat {									return false, "", false								}								// For allowCompat models, pass through effort directly without validation								if allowCompat {									effort, ok := util.ReasoningEffortFromMetadata(metadata)									if ok && strings.TrimSpace(effort) != "" {										return true, strings.ToLower(strings.TrimSpace(effort)), false									}									// Check numeric budget fallback for allowCompat									if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {										if mapped, okMap := util.ThinkingBudgetToEffort(normalizedModel, *budget); okMap && mapped != "" {											return true, mapped, false										}									}									return false, "", false								}								if !util.ModelUsesThinkingLevels(normalizedModel) {									// Non-levels models don't support effort strings in openai									return false, "", false								}								effort, ok := util.ReasoningEffortFromMetadata(metadata)								if !ok || strings.TrimSpace(effort) == "" {									if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {										if mapped, okMap := util.ThinkingBudgetToEffort(normalizedModel, *budget); okMap {											effort = mapped											ok = true										}									}								}								if !ok || strings.TrimSpace(effort) == "" {									return false, "", false								}								effort = strings.ToLower(strings.TrimSpace(effort))								if normalized, okLevel := util.NormalizeReasoningEffortLevel(normalizedModel, effort); okLevel {									return true, normalized, false								}								return false, "", true // validation would fail							case "codex":								// Codex does not support allowCompat; require thinking-capable level models.								if !util.ModelSupportsThinking(normalizedModel) || !util.ModelUsesThinkingLevels(normalizedModel) {									return false, "", false								}								effort, ok := util.ReasoningEffortFromMetadata(metadata)								if ok && strings.TrimSpace(effort) != "" {									effort = strings.ToLower(strings.TrimSpace(effort))									if normalized, okLevel := util.NormalizeReasoningEffortLevel(normalizedModel, effort); okLevel {										return true, normalized, false									}									return false, "", true								}								if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {									if mapped, okMap := util.ThinkingBudgetToEffort(normalizedModel, *budget); okMap && mapped != "" {										mapped = strings.ToLower(strings.TrimSpace(mapped))										if normalized, okLevel := util.NormalizeReasoningEffortLevel(normalizedModel, mapped); okLevel {											return true, normalized, false										}										return false, "", true									}								}								if from != "openai-response" {									// Codex translators default reasoning.effort to "medium" when									// no explicit thinking suffix/metadata is provided.									return true, "medium", false								}								return false, "", false							default:								return false, "", false							}						}() 						body, err := buildBodyForProtocol(t, from, to, cs.modelSuffix)						actualPresent, actualValue := func() (bool, string) {							path := ""							switch to {							case "gemini":								path = "generationConfig.thinkingConfig.thinkingBudget"							case "claude":								path = "thinking.budget_tokens"							case "openai":								path = "reasoning_effort"							case "codex":								path = "reasoning.effort"							}							if path == "" {								return false, ""							}							val := gjson.GetBytes(body, path)							if to == "codex" && !val.Exists() {								reasoning := gjson.GetBytes(body, "reasoning")								if reasoning.Exists() {									val = reasoning.Get("effort")								}							}							if !val.Exists() {								return false, ""							}							if val.Type == gjson.Number {								return true, fmt.Sprintf("%d", val.Int())							}							return true, val.String()						}() 						t.Logf("from=%s to=%s model=%s suffix=%s present(expect=%v got=%v) value(expect=%s got=%s) err(expect=%v got=%v) body=%s",							from, to, model, cs.modelSuffix, expectPresent, actualPresent, expectValue, actualValue, expectErr, err != nil, string(body)) 						if expectErr {							if err == nil {								t.Fatalf("expected validation error but got none, body=%s", string(body))							}							return						}						if err != nil {							t.Fatalf("unexpected error: %v body=%s", err, string(body))						} 						if expectPresent != actualPresent {							t.Fatalf("presence mismatch: expect %v got %v body=%s", expectPresent, actualPresent, string(body))						}						if expectPresent && expectValue != actualValue {							t.Fatalf("value mismatch: expect %s got %s body=%s", expectValue, actualValue, string(body))						}					})				}			}		}	}} // buildRawPayloadWithThinking creates a payload with thinking parameters already in the body.// This tests the path where thinking comes from the raw payload, not model suffix.func buildRawPayloadWithThinking(fromProtocol, model string, thinkingParam any) []byte {	switch fromProtocol {	case "gemini":		base := fmt.Sprintf(`{"model":"%s","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, model)		if budget, ok := thinkingParam.(int); ok {			base, _ = sjson.Set(base, "generationConfig.thinkingConfig.thinkingBudget", budget)		}		return []byte(base)	case "openai-response":		base := fmt.Sprintf(`{"model":"%s","input":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`, model)		if effort, ok := thinkingParam.(string); ok && effort != "" {			base, _ = sjson.Set(base, "reasoning.effort", effort)		}		return []byte(base)	case "openai":		base := fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model)		if effort, ok := thinkingParam.(string); ok && effort != "" {			base, _ = sjson.Set(base, "reasoning_effort", effort)		}		return []byte(base)	case "claude":		base := fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model)		if budget, ok := thinkingParam.(int); ok {			base, _ = sjson.Set(base, "thinking.type", "enabled")			base, _ = sjson.Set(base, "thinking.budget_tokens", budget)		}		return []byte(base)	default:		return []byte(fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model))	}} // buildBodyForProtocolWithRawThinking translates payload with raw thinking params.func buildBodyForProtocolWithRawThinking(t *testing.T, fromProtocol, toProtocol, model string, thinkingParam any) ([]byte, error) {	t.Helper()	raw := buildRawPayloadWithThinking(fromProtocol, model, thinkingParam)	stream := fromProtocol != toProtocol 	body := sdktranslator.TranslateRequest(		sdktranslator.FromString(fromProtocol),		sdktranslator.FromString(toProtocol),		model,		raw,		stream,	) 	var err error	allowCompat := isOpenAICompatModel(model)	switch toProtocol {	case "gemini":		body = util.ApplyDefaultThinkingIfNeeded(model, body)		body = util.NormalizeGeminiThinkingBudget(model, body)		body = util.StripThinkingConfigIfUnsupported(model, body)	case "claude":		// For raw payload, Claude thinking is passed through by translator		// No additional processing needed as thinking is already in body	case "openai":		body = executor.NormalizeThinkingConfig(body, model, allowCompat)		err = executor.ValidateThinkingConfig(body, model)	case "codex":		// Codex does not support allowCompat; always use false.		body, err = normalizeCodexPayload(body, model, false)	} 	body, _ = sjson.SetBytes(body, "model", model)	body = filterThinkingBody(toProtocol, body, model, model)	return body, err} func TestRawPayloadThinkingConversions(t *testing.T) {	cleanup := registerCoreModels(t)	defer cleanup() 	type scenario struct {		name          string		thinkingParam any // int for budget, string for effort level	} 	numericName := func(budget int) string {		if budget < 0 {			return "budget-neg1"		}		return fmt.Sprintf("budget-%d", budget)

Lines 1–500 of 798.