diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 042704ffb..0cefe601d 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -488,9 +488,37 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe } payload, _ = sjson.DeleteBytes(payload, "session_id") payload = helps.EnsureGeminiLeadingUserContent(payload, "contents") + payload = normalizeAIStudioThinkingLevel(payload) return payload, translatedPayload{payload: payload, action: action, toFormat: to}, nil } +// normalizeAIStudioThinkingLevel normalizes thinking levels to Google's canonical uppercase enum values. +// The AI Studio upstream validates generationConfig.thinkingConfig.thinkingLevel case-sensitively +// and rejects lowercase values with a 400 invalid argument error. +func normalizeAIStudioThinkingLevel(payload []byte) []byte { + const path = "generationConfig.thinkingConfig.thinkingLevel" + level := gjson.GetBytes(payload, path) + if level.Type != gjson.String { + return payload + } + + normalized := strings.ToUpper(level.String()) + switch normalized { + case "MINIMAL", "LOW", "MEDIUM", "HIGH": + default: + return payload + } + if normalized == level.String() { + return payload + } + + result, err := sjson.SetBytes(payload, path, normalized) + if err != nil { + return payload + } + return result +} + func (e *AIStudioExecutor) buildEndpoint(model, action, alt string) string { base := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, model, action) if action == "streamGenerateContent" { diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go index e14aff80e..27e6b36b2 100644 --- a/internal/runtime/executor/aistudio_executor_test.go +++ b/internal/runtime/executor/aistudio_executor_test.go @@ -39,6 +39,84 @@ func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing. } } +func TestAIStudioTranslateRequestNormalizesThinkingLevel(t *testing.T) { + executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) + req := cliproxyexecutor.Request{ + Model: "gemini-3.7-flash", + Payload: []byte(`{ + "contents":[{"role":"user","parts":[{"text":"Human: Hi"}]}], + "model":"gemini-3.7-flash", + "generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}} + }`), + } + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini} + + payload, _, err := executor.translateRequest(context.Background(), req, opts, false) + if err != nil { + t.Fatalf("translateRequest() error = %v", err) + } + if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "HIGH" { + t.Fatalf("thinkingLevel = %q, want HIGH; payload=%s", got, payload) + } + if !gjson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() { + t.Fatalf("includeThoughts was lost: %s", payload) + } +} + +func TestAIStudioTranslateRequestNormalizesThinkingLevelAfterPayloadOverride(t *testing.T) { + executor := NewAIStudioExecutor(&config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "gemini-3.7-flash", Protocol: "gemini"}}, + Params: map[string]any{"generationConfig.thinkingConfig.thinkingLevel": "medium"}, + }}}}, "aistudio", nil) + req := cliproxyexecutor.Request{ + Model: "gemini-3.7-flash", + Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"model":"gemini-3.7-flash"}`), + } + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini} + + payload, _, err := executor.translateRequest(context.Background(), req, opts, false) + if err != nil { + t.Fatalf("translateRequest() error = %v", err) + } + if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "MEDIUM" { + t.Fatalf("thinkingLevel = %q, want MEDIUM; payload=%s", got, payload) + } +} + +func TestNormalizeAIStudioThinkingLevel(t *testing.T) { + tests := []struct { + name string + levelJSON string + want string + unchanged bool + }{ + {name: "minimal", levelJSON: `"minimal"`, want: "MINIMAL"}, + {name: "low", levelJSON: `"low"`, want: "LOW"}, + {name: "medium", levelJSON: `"medium"`, want: "MEDIUM"}, + {name: "mixed case high", levelJSON: `"hIgH"`, want: "HIGH"}, + {name: "already uppercase", levelJSON: `"HIGH"`, want: "HIGH", unchanged: true}, + {name: "none", levelJSON: `"none"`, want: "none", unchanged: true}, + {name: "xhigh", levelJSON: `"xhigh"`, want: "xhigh", unchanged: true}, + {name: "max", levelJSON: `"max"`, want: "max", unchanged: true}, + {name: "custom", levelJSON: `"custom"`, want: "custom", unchanged: true}, + {name: "non-string", levelJSON: `1`, want: "1", unchanged: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := []byte(fmt.Sprintf(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":%s}},"marker":true}`, test.levelJSON)) + output := normalizeAIStudioThinkingLevel(input) + level := gjson.GetBytes(output, "generationConfig.thinkingConfig.thinkingLevel") + if got := level.String(); got != test.want { + t.Fatalf("thinkingLevel = %q, want %q; payload=%s", got, test.want, output) + } + if test.unchanged && string(output) != string(input) { + t.Fatalf("payload changed from %s to %s", input, output) + } + }) + } +} + func TestAIStudioTranslateRequestPrependsLeadingUserForIssue4959ResponsesHistory(t *testing.T) { executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) _, body, err := executor.translateRequest(context.Background(), cliproxyexecutor.Request{