mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
383 lines
14 KiB
Go
383 lines
14 KiB
Go
// Package geminicli implements thinking configuration for Gemini CLI API format.
|
|
package geminicli
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestNewApplier(t *testing.T) {
|
|
applier := NewApplier()
|
|
if applier == nil {
|
|
t.Fatal("NewApplier() returned nil")
|
|
}
|
|
}
|
|
|
|
func TestApplierImplementsInterface(t *testing.T) {
|
|
// Compile-time check: if Applier doesn't implement the interface, this won't compile
|
|
var _ thinking.ProviderApplier = (*Applier)(nil)
|
|
}
|
|
|
|
// TestGeminiCLIApply tests the Gemini CLI thinking applier.
|
|
//
|
|
// Gemini CLI uses request.generationConfig.thinkingConfig.* path.
|
|
// Behavior mirrors Gemini applier but with different JSON path prefix.
|
|
func TestGeminiCLIApply(t *testing.T) {
|
|
applier := NewApplier()
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
config thinking.ThinkingConfig
|
|
wantField string
|
|
wantValue interface{}
|
|
wantIncludeThoughts bool
|
|
}{
|
|
// Budget mode (no Levels)
|
|
{"budget 8k", "gemini-cli-budget", thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 8192}, "thinkingBudget", 8192, true},
|
|
{"budget zero", "gemini-cli-budget", thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 0}, "thinkingBudget", 0, false},
|
|
{"none mode", "gemini-cli-budget", thinking.ThinkingConfig{Mode: thinking.ModeNone, Budget: 0}, "thinkingBudget", 0, false},
|
|
{"auto mode", "gemini-cli-budget", thinking.ThinkingConfig{Mode: thinking.ModeAuto, Budget: -1}, "thinkingBudget", -1, true},
|
|
|
|
// Level mode (has Levels)
|
|
{"level high", "gemini-cli-level", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelHigh}, "thinkingLevel", "high", true},
|
|
{"level low", "gemini-cli-level", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelLow}, "thinkingLevel", "low", true},
|
|
{"level minimal", "gemini-cli-level", thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelMinimal}, "thinkingLevel", "minimal", true},
|
|
// ModeAuto with Levels model still uses thinkingBudget=-1
|
|
{"auto with levels", "gemini-cli-level", thinking.ThinkingConfig{Mode: thinking.ModeAuto, Budget: -1}, "thinkingBudget", -1, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
modelInfo := buildGeminiCLIModelInfo(tt.model)
|
|
result, err := applier.Apply([]byte(`{}`), tt.config, modelInfo)
|
|
if err != nil {
|
|
t.Fatalf("Apply() error = %v", err)
|
|
}
|
|
|
|
gotField := gjson.GetBytes(result, "request.generationConfig.thinkingConfig."+tt.wantField)
|
|
switch want := tt.wantValue.(type) {
|
|
case int:
|
|
if int(gotField.Int()) != want {
|
|
t.Fatalf("%s = %d, want %d", tt.wantField, gotField.Int(), want)
|
|
}
|
|
case string:
|
|
if gotField.String() != want {
|
|
t.Fatalf("%s = %q, want %q", tt.wantField, gotField.String(), want)
|
|
}
|
|
case bool:
|
|
if gotField.Bool() != want {
|
|
t.Fatalf("%s = %v, want %v", tt.wantField, gotField.Bool(), want)
|
|
}
|
|
default:
|
|
t.Fatalf("unsupported wantValue type %T", tt.wantValue)
|
|
}
|
|
|
|
gotIncludeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts").Bool()
|
|
if gotIncludeThoughts != tt.wantIncludeThoughts {
|
|
t.Fatalf("includeThoughts = %v, want %v", gotIncludeThoughts, tt.wantIncludeThoughts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGeminiCLIApplyModeNoneWithLevel tests ModeNone with Level model.
|
|
// When ModeNone is used with a model that has Levels, includeThoughts should be false.
|
|
func TestGeminiCLIApplyModeNoneWithLevel(t *testing.T) {
|
|
applier := NewApplier()
|
|
modelInfo := buildGeminiCLIModelInfo("gemini-cli-level")
|
|
config := thinking.ThinkingConfig{Mode: thinking.ModeNone, Level: thinking.LevelLow}
|
|
|
|
result, err := applier.Apply([]byte(`{}`), config, modelInfo)
|
|
if err != nil {
|
|
t.Fatalf("Apply() error = %v", err)
|
|
}
|
|
|
|
gotIncludeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts").Bool()
|
|
if gotIncludeThoughts != false {
|
|
t.Fatalf("includeThoughts = %v, want %v", gotIncludeThoughts, false)
|
|
}
|
|
|
|
gotLevel := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel").String()
|
|
if gotLevel != "low" {
|
|
t.Fatalf("thinkingLevel = %q, want %q", gotLevel, "low")
|
|
}
|
|
}
|
|
|
|
// TestGeminiCLIApplyInvalidBody tests Apply behavior with invalid body inputs.
|
|
func TestGeminiCLIApplyInvalidBody(t *testing.T) {
|
|
applier := NewApplier()
|
|
modelInfo := buildGeminiCLIModelInfo("gemini-cli-budget")
|
|
config := thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 8192}
|
|
|
|
tests := []struct {
|
|
name string
|
|
body []byte
|
|
}{
|
|
{"nil body", nil},
|
|
{"empty body", []byte{}},
|
|
{"invalid json", []byte("{\"not json\"")},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := applier.Apply(tt.body, config, modelInfo)
|
|
if err != nil {
|
|
t.Fatalf("Apply() error = %v", err)
|
|
}
|
|
|
|
gotBudget := int(gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget").Int())
|
|
if gotBudget != 8192 {
|
|
t.Fatalf("thinkingBudget = %d, want %d", gotBudget, 8192)
|
|
}
|
|
|
|
gotIncludeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts").Bool()
|
|
if !gotIncludeThoughts {
|
|
t.Fatalf("includeThoughts = %v, want %v", gotIncludeThoughts, true)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGeminiCLIApplyConflictingFields tests that conflicting fields are removed.
|
|
//
|
|
// When applying Budget format, any existing thinkingLevel should be removed.
|
|
// When applying Level format, any existing thinkingBudget should be removed.
|
|
func TestGeminiCLIApplyConflictingFields(t *testing.T) {
|
|
applier := NewApplier()
|
|
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
config thinking.ThinkingConfig
|
|
existingBody string
|
|
wantField string // expected field to exist
|
|
wantNoField string // expected field to NOT exist
|
|
}{
|
|
// Budget format should remove existing thinkingLevel
|
|
{
|
|
"budget removes level",
|
|
"gemini-cli-budget",
|
|
thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 8192},
|
|
`{"request":{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}}`,
|
|
"thinkingBudget",
|
|
"thinkingLevel",
|
|
},
|
|
// Level format should remove existing thinkingBudget
|
|
{
|
|
"level removes budget",
|
|
"gemini-cli-level",
|
|
thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelHigh},
|
|
`{"request":{"generationConfig":{"thinkingConfig":{"thinkingBudget":8192}}}}`,
|
|
"thinkingLevel",
|
|
"thinkingBudget",
|
|
},
|
|
// ModeAuto uses budget format, should remove thinkingLevel
|
|
{
|
|
"auto removes level",
|
|
"gemini-cli-level",
|
|
thinking.ThinkingConfig{Mode: thinking.ModeAuto, Budget: -1},
|
|
`{"request":{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}}`,
|
|
"thinkingBudget",
|
|
"thinkingLevel",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
modelInfo := buildGeminiCLIModelInfo(tt.model)
|
|
result, err := applier.Apply([]byte(tt.existingBody), tt.config, modelInfo)
|
|
if err != nil {
|
|
t.Fatalf("Apply() error = %v", err)
|
|
}
|
|
|
|
// Verify expected field exists
|
|
wantPath := "request.generationConfig.thinkingConfig." + tt.wantField
|
|
if !gjson.GetBytes(result, wantPath).Exists() {
|
|
t.Fatalf("%s should exist in result: %s", tt.wantField, string(result))
|
|
}
|
|
|
|
// Verify conflicting field was removed
|
|
noPath := "request.generationConfig.thinkingConfig." + tt.wantNoField
|
|
if gjson.GetBytes(result, noPath).Exists() {
|
|
t.Fatalf("%s should NOT exist in result: %s", tt.wantNoField, string(result))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGeminiCLIApplyThinkingNotSupported tests error handling when modelInfo.Thinking is nil.
|
|
func TestGeminiCLIApplyThinkingNotSupported(t *testing.T) {
|
|
applier := NewApplier()
|
|
config := thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 8192}
|
|
|
|
// Model with nil Thinking support
|
|
modelInfo := ®istry.ModelInfo{ID: "gemini-cli-unknown", Thinking: nil}
|
|
|
|
_, err := applier.Apply([]byte(`{}`), config, modelInfo)
|
|
if err == nil {
|
|
t.Fatal("Apply() expected error for nil Thinking, got nil")
|
|
}
|
|
|
|
// Verify it's the correct error type
|
|
thinkErr, ok := err.(*thinking.ThinkingError)
|
|
if !ok {
|
|
t.Fatalf("Apply() error type = %T, want *thinking.ThinkingError", err)
|
|
}
|
|
if thinkErr.Code != thinking.ErrThinkingNotSupported {
|
|
t.Fatalf("Apply() error code = %v, want %v", thinkErr.Code, thinking.ErrThinkingNotSupported)
|
|
}
|
|
}
|
|
|
|
// TestGeminiCLIApplyNilModelInfo tests Apply behavior when modelInfo is nil.
|
|
func TestGeminiCLIApplyNilModelInfo(t *testing.T) {
|
|
applier := NewApplier()
|
|
config := thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 8192}
|
|
body := []byte(`{"existing": "data"}`)
|
|
|
|
result, err := applier.Apply(body, config, nil)
|
|
if err != nil {
|
|
t.Fatalf("Apply() with nil modelInfo should not error, got: %v", err)
|
|
}
|
|
if string(result) != string(body) {
|
|
t.Fatalf("Apply() with nil modelInfo should return original body, got: %s", result)
|
|
}
|
|
}
|
|
|
|
// TestGeminiCLIApplyEmptyModelID tests Apply when modelID is empty.
|
|
func TestGeminiCLIApplyEmptyModelID(t *testing.T) {
|
|
applier := NewApplier()
|
|
config := thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 8192}
|
|
modelInfo := ®istry.ModelInfo{ID: "", Thinking: nil}
|
|
|
|
_, err := applier.Apply([]byte(`{}`), config, modelInfo)
|
|
if err == nil {
|
|
t.Fatal("Apply() with empty modelID and nil Thinking should error")
|
|
}
|
|
thinkErr, ok := err.(*thinking.ThinkingError)
|
|
if !ok {
|
|
t.Fatalf("Apply() error type = %T, want *thinking.ThinkingError", err)
|
|
}
|
|
if thinkErr.Model != "unknown" {
|
|
t.Fatalf("Apply() error model = %q, want %q", thinkErr.Model, "unknown")
|
|
}
|
|
}
|
|
|
|
// TestGeminiCLIApplyModeBudgetWithLevels tests that ModeBudget with Levels model passes through.
|
|
// Apply layer doesn't convert - upper layer should handle Budget→Level conversion.
|
|
func TestGeminiCLIApplyModeBudgetWithLevels(t *testing.T) {
|
|
applier := NewApplier()
|
|
modelInfo := buildGeminiCLIModelInfo("gemini-cli-level")
|
|
config := thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 8192}
|
|
body := []byte(`{"existing": "data"}`)
|
|
|
|
result, err := applier.Apply(body, config, modelInfo)
|
|
if err != nil {
|
|
t.Fatalf("Apply() error = %v", err)
|
|
}
|
|
// ModeBudget with Levels model: Apply returns body unchanged (conversion is upper layer's job)
|
|
if string(result) != string(body) {
|
|
t.Fatalf("Apply() ModeBudget with Levels should return original body, got: %s", result)
|
|
}
|
|
}
|
|
|
|
// TestGeminiCLIApplyUnsupportedMode tests behavior with unsupported Mode types.
|
|
func TestGeminiCLIApplyUnsupportedMode(t *testing.T) {
|
|
applier := NewApplier()
|
|
body := []byte(`{"existing": "data"}`)
|
|
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
config thinking.ThinkingConfig
|
|
}{
|
|
{"unknown mode with budget model", "gemini-cli-budget", thinking.ThinkingConfig{Mode: thinking.ThinkingMode(99), Budget: 8192}},
|
|
{"unknown mode with level model", "gemini-cli-level", thinking.ThinkingConfig{Mode: thinking.ThinkingMode(99), Level: thinking.LevelHigh}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
modelInfo := buildGeminiCLIModelInfo(tt.model)
|
|
result, err := applier.Apply(body, tt.config, modelInfo)
|
|
if err != nil {
|
|
t.Fatalf("Apply() error = %v", err)
|
|
}
|
|
// Unsupported modes return original body unchanged
|
|
if string(result) != string(body) {
|
|
t.Fatalf("Apply() with unsupported mode should return original body, got: %s", result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAntigravityUsesGeminiCLIFormat tests that antigravity provider uses gemini-cli format.
|
|
// Antigravity is registered with the same applier as gemini-cli.
|
|
func TestAntigravityUsesGeminiCLIFormat(t *testing.T) {
|
|
applier := NewApplier()
|
|
|
|
tests := []struct {
|
|
name string
|
|
config thinking.ThinkingConfig
|
|
modelInfo *registry.ModelInfo
|
|
wantField string
|
|
}{
|
|
{
|
|
"claude model budget",
|
|
thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 16384},
|
|
®istry.ModelInfo{ID: "gemini-claude-sonnet-4-5-thinking", Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 200000}},
|
|
"request.generationConfig.thinkingConfig.thinkingBudget",
|
|
},
|
|
{
|
|
"opus model budget",
|
|
thinking.ThinkingConfig{Mode: thinking.ModeBudget, Budget: 32768},
|
|
®istry.ModelInfo{ID: "gemini-claude-opus-4-5-thinking", Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 200000}},
|
|
"request.generationConfig.thinkingConfig.thinkingBudget",
|
|
},
|
|
{
|
|
"model with levels",
|
|
thinking.ThinkingConfig{Mode: thinking.ModeLevel, Level: thinking.LevelHigh},
|
|
®istry.ModelInfo{ID: "some-model-with-levels", Thinking: ®istry.ThinkingSupport{Min: 1024, Max: 200000, Levels: []string{"low", "high"}}},
|
|
"request.generationConfig.thinkingConfig.thinkingLevel",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := applier.Apply([]byte(`{}`), tt.config, tt.modelInfo)
|
|
if err != nil {
|
|
t.Fatalf("Apply() error = %v", err)
|
|
}
|
|
|
|
if !gjson.GetBytes(got, tt.wantField).Exists() {
|
|
t.Fatalf("expected field %s in output: %s", tt.wantField, string(got))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func buildGeminiCLIModelInfo(modelID string) *registry.ModelInfo {
|
|
support := ®istry.ThinkingSupport{}
|
|
switch modelID {
|
|
case "gemini-cli-budget":
|
|
support.Min = 0
|
|
support.Max = 32768
|
|
support.ZeroAllowed = true
|
|
support.DynamicAllowed = true
|
|
case "gemini-cli-level":
|
|
support.Min = 128
|
|
support.Max = 32768
|
|
support.ZeroAllowed = false
|
|
support.DynamicAllowed = true
|
|
support.Levels = []string{"minimal", "low", "medium", "high"}
|
|
default:
|
|
// Unknown model - return nil Thinking to trigger error path
|
|
return ®istry.ModelInfo{ID: modelID, Thinking: nil}
|
|
}
|
|
return ®istry.ModelInfo{
|
|
ID: modelID,
|
|
Thinking: support,
|
|
}
|
|
}
|