- Normalize `generationConfig.thinkingConfig.thinkingLevel` to canonical uppercase enum values (`MINIMAL`, `LOW`, `MEDIUM`, `HIGH`). - Prevent upstream HTTP 400 invalid argument errors caused by case-sensitive validation.
Unified · Split
| @@ -488,9 +488,37 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe · 487 unmodified lines | |||
| 488 | } | 488 | } |
| 489 | payload, _ = sjson.DeleteBytes(payload, "session_id") | 489 | payload, _ = sjson.DeleteBytes(payload, "session_id") |
| 490 | payload = helps.EnsureGeminiLeadingUserContent(payload, "contents") | 490 | payload = helps.EnsureGeminiLeadingUserContent(payload, "contents") |
| 491 | payload = normalizeAIStudioThinkingLevel(payload) | ||
| 491 | return payload, translatedPayload{payload: payload, action: action, toFormat: to}, nil | 492 | return payload, translatedPayload{payload: payload, action: action, toFormat: to}, nil |
| 492 | } | 493 | } |
| 493 | 494 | ||
| 495 | // normalizeAIStudioThinkingLevel normalizes thinking levels to Google's canonical uppercase enum values. | ||
| 496 | // The AI Studio upstream validates generationConfig.thinkingConfig.thinkingLevel case-sensitively | ||
| 497 | // and rejects lowercase values with a 400 invalid argument error. | ||
| 498 | func normalizeAIStudioThinkingLevel(payload []byte) []byte { | ||
| 499 | const path = "generationConfig.thinkingConfig.thinkingLevel" | ||
| 500 | level := gjson.GetBytes(payload, path) | ||
| 501 | if level.Type != gjson.String { | ||
| 502 | return payload | ||
| 503 | } | ||
| 504 | |||
| 505 | normalized := strings.ToUpper(level.String()) | ||
| 506 | switch normalized { | ||
| 507 | case "MINIMAL", "LOW", "MEDIUM", "HIGH": | ||
| 508 | default: | ||
| 509 | return payload | ||
| 510 | } | ||
| 511 | if normalized == level.String() { | ||
| 512 | return payload | ||
| 513 | } | ||
| 514 | |||
| 515 | result, err := sjson.SetBytes(payload, path, normalized) | ||
| 516 | if err != nil { | ||
| 517 | return payload | ||
| 518 | } | ||
| 519 | return result | ||
| 520 | } | ||
| 521 | |||
| 494 | func (e *AIStudioExecutor) buildEndpoint(model, action, alt string) string { | 522 | func (e *AIStudioExecutor) buildEndpoint(model, action, alt string) string { |
| 495 | base := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, model, action) | 523 | base := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, model, action) |
| 496 | if action == "streamGenerateContent" { | 524 | if action == "streamGenerateContent" { |
| @@ -39,6 +39,84 @@ func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing. · 38 unmodified lines | |||
| 39 | } | 39 | } |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | func TestAIStudioTranslateRequestNormalizesThinkingLevel(t *testing.T) { | ||
| 43 | executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) | ||
| 44 | req := cliproxyexecutor.Request{ | ||
| 45 | Model: "gemini-3.7-flash", | ||
| 46 | Payload: []byte(`{ | ||
| 47 | "contents":[{"role":"user","parts":[{"text":"Human: Hi"}]}], | ||
| 48 | "model":"gemini-3.7-flash", | ||
| 49 | "generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}} | ||
| 50 | }`), | ||
| 51 | } | ||
| 52 | opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini} | ||
| 53 | |||
| 54 | payload, _, err := executor.translateRequest(context.Background(), req, opts, false) | ||
| 55 | if err != nil { | ||
| 56 | t.Fatalf("translateRequest() error = %v", err) | ||
| 57 | } | ||
| 58 | if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "HIGH" { | ||
| 59 | t.Fatalf("thinkingLevel = %q, want HIGH; payload=%s", got, payload) | ||
| 60 | } | ||
| 61 | if !gjson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() { | ||
| 62 | t.Fatalf("includeThoughts was lost: %s", payload) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | func TestAIStudioTranslateRequestNormalizesThinkingLevelAfterPayloadOverride(t *testing.T) { | ||
| 67 | executor := NewAIStudioExecutor(&config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{ | ||
| 68 | Models: []config.PayloadModelRule{{Name: "gemini-3.7-flash", Protocol: "gemini"}}, | ||
| 69 | Params: map[string]any{"generationConfig.thinkingConfig.thinkingLevel": "medium"}, | ||
| 70 | }}}}, "aistudio", nil) | ||
| 71 | req := cliproxyexecutor.Request{ | ||
| 72 | Model: "gemini-3.7-flash", | ||
| 73 | Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"model":"gemini-3.7-flash"}`), | ||
| 74 | } | ||
| 75 | opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini} | ||
| 76 | |||
| 77 | payload, _, err := executor.translateRequest(context.Background(), req, opts, false) | ||
| 78 | if err != nil { | ||
| 79 | t.Fatalf("translateRequest() error = %v", err) | ||
| 80 | } | ||
| 81 | if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "MEDIUM" { | ||
| 82 | t.Fatalf("thinkingLevel = %q, want MEDIUM; payload=%s", got, payload) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | func TestNormalizeAIStudioThinkingLevel(t *testing.T) { | ||
| 87 | tests := []struct { | ||
| 88 | name string | ||
| 89 | levelJSON string | ||
| 90 | want string | ||
| 91 | unchanged bool | ||
| 92 | }{ | ||
| 93 | {name: "minimal", levelJSON: `"minimal"`, want: "MINIMAL"}, | ||
| 94 | {name: "low", levelJSON: `"low"`, want: "LOW"}, | ||
| 95 | {name: "medium", levelJSON: `"medium"`, want: "MEDIUM"}, | ||
| 96 | {name: "mixed case high", levelJSON: `"hIgH"`, want: "HIGH"}, | ||
| 97 | {name: "already uppercase", levelJSON: `"HIGH"`, want: "HIGH", unchanged: true}, | ||
| 98 | {name: "none", levelJSON: `"none"`, want: "none", unchanged: true}, | ||
| 99 | {name: "xhigh", levelJSON: `"xhigh"`, want: "xhigh", unchanged: true}, | ||
| 100 | {name: "max", levelJSON: `"max"`, want: "max", unchanged: true}, | ||
| 101 | {name: "custom", levelJSON: `"custom"`, want: "custom", unchanged: true}, | ||
| 102 | {name: "non-string", levelJSON: `1`, want: "1", unchanged: true}, | ||
| 103 | } | ||
| 104 | |||
| 105 | for _, test := range tests { | ||
| 106 | t.Run(test.name, func(t *testing.T) { | ||
| 107 | input := []byte(fmt.Sprintf(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":%s}},"marker":true}`, test.levelJSON)) | ||
| 108 | output := normalizeAIStudioThinkingLevel(input) | ||
| 109 | level := gjson.GetBytes(output, "generationConfig.thinkingConfig.thinkingLevel") | ||
| 110 | if got := level.String(); got != test.want { | ||
| 111 | t.Fatalf("thinkingLevel = %q, want %q; payload=%s", got, test.want, output) | ||
| 112 | } | ||
| 113 | if test.unchanged && string(output) != string(input) { | ||
| 114 | t.Fatalf("payload changed from %s to %s", input, output) | ||
| 115 | } | ||
| 116 | }) | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 42 | func TestAIStudioTranslateRequestPrependsLeadingUserForIssue4959ResponsesHistory(t *testing.T) { | 120 | func TestAIStudioTranslateRequestPrependsLeadingUserForIssue4959ResponsesHistory(t *testing.T) { |
| 43 | executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) | 121 | executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil) |
| 44 | _, body, err := executor.translateRequest(context.Background(), cliproxyexecutor.Request{ | 122 | _, body, err := executor.translateRequest(context.Background(), cliproxyexecutor.Request{ |