History

Merge branch 'router-for-me:main' into main

Luis Pater committed 10 months agov6.6.76-0

Commit ba486ca2 parents 19232c6 750b93018 files · +1226 −131Browse files

Unified · Split

18 files
  1. internal/registry/model_definitions.go +2 −2
  2. internal/registry/model_registry.go +125 −0
  3. internal/runtime/executor/antigravity_executor.go +8 −8
  4. internal/runtime/executor/gemini_cli_executor.go +5 −5
  5. internal/runtime/executor/iflow_executor.go +24 −37
  6. internal/runtime/executor/payload_helpers.go +30 −8
  7. internal/translator/openai/claude/openai_claude_request.go +133 −44
  8. internal/translator/openai/claude/openai_claude_request_test.go +500 −0
  9. internal/translator/openai/claude/openai_claude_response.go +4 −4
  10. internal/util/gemini_schema.go +5 −0
  11. internal/util/gemini_schema_test.go +3 −1
  12. internal/util/gemini_thinking.go +55 −17
  13. internal/util/sanitize_test.go +56 −0
  14. internal/util/thinking.go +21 −4
  15. internal/util/util.go +40 −0
  16. sdk/cliproxy/auth/model_name_mappings.go +3 −1
  17. sdk/cliproxy/model_registry.go +1 −0
  18. test/model_alias_thinking_suffix_test.go +211 −0
internal/registry/model_definitions.go+2−2
@@ -740,7 +740,7 @@ func GetIFlowModels() []*ModelInfo { · 739 unmodified lines
740740 {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},
741741 {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},
742742 {ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
743− {ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000},
743+ {ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport},
744744 {ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
745745 }
746746 models := make([]*ModelInfo, 0, len(entries))
@@ -773,7 +773,7 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { · 26 unmodified lines
773773 return map[string]*AntigravityModelConfig{
774774 "gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"},
775775 "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash-lite"},
776− "gemini-2.5-computer-use-preview-10-2025": {Name: "models/gemini-2.5-computer-use-preview-10-2025"},
776+ "gemini-2.5-computer-use-preview-10-2025": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/gemini-2.5-computer-use-preview-10-2025"},
777777 "gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-preview"},
778778 "gemini-3-pro-image-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-image-preview"},
779779 "gemini-3-flash-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, Name: "models/gemini-3-flash-preview"},
internal/registry/model_registry.go+125−0
@@ -625,6 +625,131 @@ func (r *ModelRegistry) GetAvailableModels(handlerType string) []map[string]any · 624 unmodified lines
625625 return models
626626 }
627627
628+// GetAvailableModelsByProvider returns models available for the given provider identifier.
629+// Parameters:
630+// - provider: Provider identifier (e.g., "codex", "gemini", "antigravity")
631+//
632+// Returns:
633+// - []*ModelInfo: List of available models for the provider
634+func (r *ModelRegistry) GetAvailableModelsByProvider(provider string) []*ModelInfo {
635+ provider = strings.ToLower(strings.TrimSpace(provider))
636+ if provider == "" {
637+ return nil
638+ }
639+
640+ r.mutex.RLock()
641+ defer r.mutex.RUnlock()
642+
643+ type providerModel struct {
644+ count int
645+ info *ModelInfo
646+ }
647+
648+ providerModels := make(map[string]*providerModel)
649+
650+ for clientID, clientProvider := range r.clientProviders {
651+ if clientProvider != provider {
652+ continue
653+ }
654+ modelIDs := r.clientModels[clientID]
655+ if len(modelIDs) == 0 {
656+ continue
657+ }
658+ clientInfos := r.clientModelInfos[clientID]
659+ for _, modelID := range modelIDs {
660+ modelID = strings.TrimSpace(modelID)
661+ if modelID == "" {
662+ continue
663+ }
664+ entry := providerModels[modelID]
665+ if entry == nil {
666+ entry = &providerModel{}
667+ providerModels[modelID] = entry
668+ }
669+ entry.count++
670+ if entry.info == nil {
671+ if clientInfos != nil {
672+ if info := clientInfos[modelID]; info != nil {
673+ entry.info = info
674+ }
675+ }
676+ if entry.info == nil {
677+ if reg, ok := r.models[modelID]; ok && reg != nil && reg.Info != nil {
678+ entry.info = reg.Info
679+ }
680+ }
681+ }
682+ }
683+ }
684+
685+ if len(providerModels) == 0 {
686+ return nil
687+ }
688+
689+ quotaExpiredDuration := 5 * time.Minute
690+ now := time.Now()
691+ result := make([]*ModelInfo, 0, len(providerModels))
692+
693+ for modelID, entry := range providerModels {
694+ if entry == nil || entry.count <= 0 {
695+ continue
696+ }
697+ registration, ok := r.models[modelID]
698+
699+ expiredClients := 0
700+ cooldownSuspended := 0
701+ otherSuspended := 0
702+ if ok && registration != nil {
703+ if registration.QuotaExceededClients != nil {
704+ for clientID, quotaTime := range registration.QuotaExceededClients {
705+ if clientID == "" {
706+ continue
707+ }
708+ if p, okProvider := r.clientProviders[clientID]; !okProvider || p != provider {
709+ continue
710+ }
711+ if quotaTime != nil && now.Sub(*quotaTime) < quotaExpiredDuration {
712+ expiredClients++
713+ }
714+ }
715+ }
716+ if registration.SuspendedClients != nil {
717+ for clientID, reason := range registration.SuspendedClients {
718+ if clientID == "" {
719+ continue
720+ }
721+ if p, okProvider := r.clientProviders[clientID]; !okProvider || p != provider {
722+ continue
723+ }
724+ if strings.EqualFold(reason, "quota") {
725+ cooldownSuspended++
726+ continue
727+ }
728+ otherSuspended++
729+ }
730+ }
731+ }
732+
733+ availableClients := entry.count
734+ effectiveClients := availableClients - expiredClients - otherSuspended
735+ if effectiveClients < 0 {
736+ effectiveClients = 0
737+ }
738+
739+ if effectiveClients > 0 || (availableClients > 0 && (expiredClients > 0 || cooldownSuspended > 0) && otherSuspended == 0) {
740+ if entry.info != nil {
741+ result = append(result, entry.info)
742+ continue
743+ }
744+ if ok && registration != nil && registration.Info != nil {
745+ result = append(result, registration.Info)
746+ }
747+ }
748+ }
749+
750+ return result
751+}
752+
628753 // GetModelCount returns the number of available clients for a specific model
629754 // Parameters:
630755 // - modelID: The model ID to check
internal/runtime/executor/antigravity_executor.go+8−8
@@ -96,9 +96,9 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au · 95 unmodified lines
9696 to := sdktranslator.FromString("antigravity")
9797 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
9898
99− translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)
99+ translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
100100 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
101− translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
101+ translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
102102 translated = normalizeAntigravityThinking(req.Model, translated, isClaude)
103103 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
104104
@@ -191,9 +191,9 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * · 86 unmodified lines
191191 to := sdktranslator.FromString("antigravity")
192192 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
193193
194− translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)
194+ translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
195195 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
196− translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
196+ translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
197197 translated = normalizeAntigravityThinking(req.Model, translated, true)
198198 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
199199
@@ -527,9 +527,9 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya · 327 unmodified lines
527527 to := sdktranslator.FromString("antigravity")
528528 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
529529
530− translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)
530+ translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
531531 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
532− translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
532+ translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
533533 translated = normalizeAntigravityThinking(req.Model, translated, isClaude)
534534 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
535535
@@ -697,8 +697,8 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut · 161 unmodified lines
697697
698698 for idx, baseURL := range baseURLs {
699699 payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
700− payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model)
701− payload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, payload)
700+ payload = ApplyThinkingMetadataCLI(payload, req.Metadata, req.Model)
701+ payload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, payload)
702702 payload = normalizeAntigravityThinking(req.Model, payload, isClaude)
703703 payload = deleteJSONField(payload, "project")
704704 payload = deleteJSONField(payload, "model")
internal/runtime/executor/gemini_cli_executor.go+5−5
@@ -78,9 +78,9 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 77 unmodified lines
7878 from := opts.SourceFormat
7979 to := sdktranslator.FromString("gemini-cli")
8080 basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
81− basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
81+ basePayload = ApplyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
8282 basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)
83− basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload)
83+ basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload)
8484 basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)
8585 basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
8686 basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
@@ -217,9 +217,9 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 130 unmodified lines
217217 from := opts.SourceFormat
218218 to := sdktranslator.FromString("gemini-cli")
219219 basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
220− basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
220+ basePayload = ApplyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
221221 basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)
222− basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload)
222+ basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload)
223223 basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)
224224 basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
225225 basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
@@ -421,7 +421,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth. · 195 unmodified lines
421421 // Gemini CLI endpoint when iterating fallback variants.
422422 for _, attemptModel := range models {
423423 payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false)
424− payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model)
424+ payload = ApplyThinkingMetadataCLI(payload, req.Metadata, req.Model)
425425 payload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, payload)
426426 payload = deleteJSONField(payload, "project")
427427 payload = deleteJSONField(payload, "model")
internal/runtime/executor/iflow_executor.go+24−37
@@ -441,21 +441,18 @@ func ensureToolsArray(body []byte) []byte { · 440 unmodified lines
441441 return updated
442442 }
443443
444−// preserveReasoningContentInMessages ensures reasoning_content from assistant messages in the
445−// conversation history is preserved when sending to iFlow models that support thinking.
446−// This is critical for multi-turn conversations where the model needs to see its previous
447−// reasoning to maintain coherent thought chains across tool calls and conversation turns.
444+// preserveReasoningContentInMessages checks if reasoning_content from assistant messages
445+// is preserved in conversation history for iFlow models that support thinking.
446+// This is helpful for multi-turn conversations where the model may benefit from seeing
447+// its previous reasoning to maintain coherent thought chains.
448448 //
449−// For GLM-4.7 and MiniMax-M2.1, the full assistant response (including reasoning) must be
450−// appended back into message history before the next call.
449+// For GLM-4.6/4.7 and MiniMax M2/M2.1, it is recommended to include the full assistant
450+// response (including reasoning_content) in message history for better context continuity.
451451 func preserveReasoningContentInMessages(body []byte) []byte {
452452 model := strings.ToLower(gjson.GetBytes(body, "model").String())
453453
454454 // Only apply to models that support thinking with history preservation
455− needsPreservation := strings.HasPrefix(model, "glm-4.7") ||
456− strings.HasPrefix(model, "glm-4-7") ||
457− strings.HasPrefix(model, "minimax-m2.1") ||
458− strings.HasPrefix(model, "minimax-m2-1")
455+ needsPreservation := strings.HasPrefix(model, "glm-4") || strings.HasPrefix(model, "minimax-m2")
459456
460457 if !needsPreservation {
461458 return body
@@ -493,46 +490,36 @@ func preserveReasoningContentInMessages(body []byte) []byte { · 31 unmodified lines
493490 // This should be called after NormalizeThinkingConfig has processed the payload.
494491 //
495492 // Model-specific handling:
496−// - GLM-4.7: Uses extra_body={"thinking": {"type": "enabled"}, "clear_thinking": false}
497−// - MiniMax-M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation
498−// - Other iFlow models: Uses chat_template_kwargs.enable_thinking (boolean)
493+// - GLM-4.6/4.7: Uses chat_template_kwargs.enable_thinking (boolean) and chat_template_kwargs.clear_thinking=false
494+// - MiniMax M2/M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation
499495 func applyIFlowThinkingConfig(body []byte) []byte {
500496 effort := gjson.GetBytes(body, "reasoning_effort")
501− model := strings.ToLower(gjson.GetBytes(body, "model").String())
502−
503− // Check if thinking should be enabled
504− val := ""
505− if effort.Exists() {
506− val = strings.ToLower(strings.TrimSpace(effort.String()))
497+ if !effort.Exists() {
498+ return body
507499 }
508− enableThinking := effort.Exists() && val != "none" && val != ""
500+
501+ model := strings.ToLower(gjson.GetBytes(body, "model").String())
502+ val := strings.ToLower(strings.TrimSpace(effort.String()))
503+ enableThinking := val != "none" && val != ""
509504
510505 // Remove reasoning_effort as we'll convert to model-specific format
511− if effort.Exists() {
512− body, _ = sjson.DeleteBytes(body, "reasoning_effort")
513− }
506+ body, _ = sjson.DeleteBytes(body, "reasoning_effort")
507+ body, _ = sjson.DeleteBytes(body, "thinking")
514508
515− // GLM-4.7: Use extra_body with thinking config and clear_thinking: false
516− if strings.HasPrefix(model, "glm-4.7") || strings.HasPrefix(model, "glm-4-7") {
509+ // GLM-4.6/4.7: Use chat_template_kwargs
510+ if strings.HasPrefix(model, "glm-4") {
511+ body, _ = sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
517512 if enableThinking {
518− body, _ = sjson.SetBytes(body, "extra_body.thinking.type", "enabled")
519− body, _ = sjson.SetBytes(body, "extra_body.clear_thinking", false)
513+ body, _ = sjson.SetBytes(body, "chat_template_kwargs.clear_thinking", false)
520514 }
521515 return body
522516 }
523517
524− // MiniMax-M2.1: Use reasoning_split=true for interleaved thinking
525− if strings.HasPrefix(model, "minimax-m2.1") || strings.HasPrefix(model, "minimax-m2-1") {
526− if enableThinking {
527− body, _ = sjson.SetBytes(body, "reasoning_split", true)
528− }
518+ // MiniMax M2/M2.1: Use reasoning_split
519+ if strings.HasPrefix(model, "minimax-m2") {
520+ body, _ = sjson.SetBytes(body, "reasoning_split", enableThinking)
529521 return body
530522 }
531523
532− // Other iFlow models (including GLM-4.6): Use chat_template_kwargs.enable_thinking
533− if effort.Exists() {
534− body, _ = sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
535− }
536−
537524 return body
538525 }
internal/runtime/executor/payload_helpers.go+30−8
@@ -14,32 +14,54 @@ import ( · 13 unmodified lines
1414 // ApplyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192))
1515 // for standard Gemini format payloads. It normalizes the budget when the model supports thinking.
1616 func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte {
17− budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata)
17+ // Use the alias from metadata if available, as it's registered in the global registry
18+ // with thinking metadata; the upstream model name may not be registered.
19+ lookupModel := util.ResolveOriginalModel(model, metadata)
20+
21+ // Determine which model to use for thinking support check.
22+ // If the alias (lookupModel) is not in the registry, fall back to the upstream model.
23+ thinkingModel := lookupModel
24+ if !util.ModelSupportsThinking(lookupModel) && util.ModelSupportsThinking(model) {
25+ thinkingModel = model
26+ }
27+
28+ budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(thinkingModel, metadata)
1829 if !ok || (budgetOverride == nil && includeOverride == nil) {
1930 return payload
2031 }
21− if !util.ModelSupportsThinking(model) {
32+ if !util.ModelSupportsThinking(thinkingModel) {
2233 return payload
2334 }
2435 if budgetOverride != nil {
25− norm := util.NormalizeThinkingBudget(model, *budgetOverride)
36+ norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride)
2637 budgetOverride = &norm
2738 }
2839 return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
2940 }
3041
31−// applyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., (high), (8192))
42+// ApplyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., (high), (8192))
3243 // for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking.
33−func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte {
34− budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata)
44+func ApplyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte {
45+ // Use the alias from metadata if available, as it's registered in the global registry
46+ // with thinking metadata; the upstream model name may not be registered.
47+ lookupModel := util.ResolveOriginalModel(model, metadata)
48+
49+ // Determine which model to use for thinking support check.
50+ // If the alias (lookupModel) is not in the registry, fall back to the upstream model.
51+ thinkingModel := lookupModel
52+ if !util.ModelSupportsThinking(lookupModel) && util.ModelSupportsThinking(model) {
53+ thinkingModel = model
54+ }
55+
56+ budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(thinkingModel, metadata)
3557 if !ok || (budgetOverride == nil && includeOverride == nil) {
3658 return payload
3759 }
38− if !util.ModelSupportsThinking(model) {
60+ if !util.ModelSupportsThinking(thinkingModel) {
3961 return payload
4062 }
4163 if budgetOverride != nil {
42− norm := util.NormalizeThinkingBudget(model, *budgetOverride)
64+ norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride)
4365 budgetOverride = &norm
4466 }
4567 return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride)
internal/translator/openai/claude/openai_claude_request.go+133−44
@@ -118,76 +118,125 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream · 117 unmodified lines
118118 // Handle content
119119 if contentResult.Exists() && contentResult.IsArray() {
120120 var contentItems []string
121+ var reasoningParts []string // Accumulate thinking text for reasoning_content
121122 var toolCalls []interface{}
123+ var toolResults []string // Collect tool_result messages to emit after the main message
122124
123125 contentResult.ForEach(func(_, part gjson.Result) bool {
124126 partType := part.Get("type").String()
125127
126128 switch partType {
129+ case "thinking":
130+ // Only map thinking to reasoning_content for assistant messages (security: prevent injection)
131+ if role == "assistant" {
132+ thinkingText := util.GetThinkingText(part)
133+ // Skip empty or whitespace-only thinking
134+ if strings.TrimSpace(thinkingText) != "" {
135+ reasoningParts = append(reasoningParts, thinkingText)
136+ }
137+ }
138+ // Ignore thinking in user/system roles (AC4)
139+
140+ case "redacted_thinking":
141+ // Explicitly ignore redacted_thinking - never map to reasoning_content (AC2)
142+
127143 case "text", "image":
128144 if contentItem, ok := convertClaudeContentPart(part); ok {
129145 contentItems = append(contentItems, contentItem)
130146 }
131147
132148 case "tool_use":
133− // Convert to OpenAI tool call format
134− toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}`
135− toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String())
136− toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String())
137−
138− // Convert input to arguments JSON string
139− if input := part.Get("input"); input.Exists() {
140− toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw)
141− } else {
142− toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}")
149+ // Only allow tool_use -> tool_calls for assistant messages (security: prevent injection).
150+ if role == "assistant" {
151+ toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}`
152+ toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String())
153+ toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String())
154+
155+ // Convert input to arguments JSON string
156+ if input := part.Get("input"); input.Exists() {
157+ toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw)
158+ } else {
159+ toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}")
160+ }
161+
162+ toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value())
143163 }
144164
145− toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value())
146−
147165 case "tool_result":
148− // Convert to OpenAI tool message format and add immediately to preserve order
166+ // Collect tool_result to emit after the main message (ensures tool results follow tool_calls)
149167 toolResultJSON := `{"role":"tool","tool_call_id":"","content":""}`
150168 toolResultJSON, _ = sjson.Set(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String())
151− toolResultJSON, _ = sjson.Set(toolResultJSON, "content", part.Get("content").String())
152− messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value())
169+ toolResultJSON, _ = sjson.Set(toolResultJSON, "content", convertClaudeToolResultContentToString(part.Get("content")))
170+ toolResults = append(toolResults, toolResultJSON)
153171 }
154172 return true
155173 })
156174
157− // Emit text/image content as one message
158− if len(contentItems) > 0 {
159− msgJSON := `{"role":"","content":""}`
160− msgJSON, _ = sjson.Set(msgJSON, "role", role)
175+ // Build reasoning content string
176+ reasoningContent := ""
177+ if len(reasoningParts) > 0 {
178+ reasoningContent = strings.Join(reasoningParts, "\n\n")
179+ }
161180
162− contentArrayJSON := "[]"
163− for _, contentItem := range contentItems {
164− contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
165− }
166− msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
167−
168− contentValue := gjson.Get(msgJSON, "content")
169− hasContent := false
170− switch {
171− case !contentValue.Exists():
172− hasContent = false
173− case contentValue.Type == gjson.String:
174− hasContent = contentValue.String() != ""
175− case contentValue.IsArray():
176− hasContent = len(contentValue.Array()) > 0
177− default:
178− hasContent = contentValue.Raw != "" && contentValue.Raw != "null"
179− }
181+ hasContent := len(contentItems) > 0
182+ hasReasoning := reasoningContent != ""
183+ hasToolCalls := len(toolCalls) > 0
184+ hasToolResults := len(toolResults) > 0
185+
186+ // OpenAI requires: tool messages MUST immediately follow the assistant message with tool_calls.
187+ // Therefore, we emit tool_result messages FIRST (they respond to the previous assistant's tool_calls),
188+ // then emit the current message's content.
189+ for _, toolResultJSON := range toolResults {
190+ messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value())
191+ }
192+
193+ // For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content
194+ // This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency
195+ if role == "assistant" {
196+ if hasContent || hasReasoning || hasToolCalls {
197+ msgJSON := `{"role":"assistant"}`
198+
199+ // Add content (as array if we have items, empty string if reasoning-only)
200+ if hasContent {
201+ contentArrayJSON := "[]"
202+ for _, contentItem := range contentItems {
203+ contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
204+ }
205+ msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
206+ } else {
207+ // Ensure content field exists for OpenAI compatibility
208+ msgJSON, _ = sjson.Set(msgJSON, "content", "")
209+ }
210+
211+ // Add reasoning_content if present
212+ if hasReasoning {
213+ msgJSON, _ = sjson.Set(msgJSON, "reasoning_content", reasoningContent)
214+ }
215+
216+ // Add tool_calls if present (in same message as content)
217+ if hasToolCalls {
218+ msgJSON, _ = sjson.Set(msgJSON, "tool_calls", toolCalls)
219+ }
180220
181− if hasContent {
182221 messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
183222 }
184− }
223+ } else {
224+ // For non-assistant roles: emit content message if we have content
225+ // If the message only contains tool_results (no text/image), we still processed them above
226+ if hasContent {
227+ msgJSON := `{"role":""}`
228+ msgJSON, _ = sjson.Set(msgJSON, "role", role)
185229
186− // Emit tool calls in a separate assistant message
187− if role == "assistant" && len(toolCalls) > 0 {
188− toolCallMsgJSON := `{"role":"assistant","tool_calls":[]}`
189− toolCallMsgJSON, _ = sjson.Set(toolCallMsgJSON, "tool_calls", toolCalls)
190− messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolCallMsgJSON).Value())
230+ contentArrayJSON := "[]"
231+ for _, contentItem := range contentItems {
232+ contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
233+ }
234+ msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)
235+
236+ messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
237+ } else if hasToolResults && !hasContent {
238+ // tool_results already emitted above, no additional user message needed
239+ }
191240 }
192241
193242 } else if contentResult.Exists() && contentResult.Type == gjson.String {
@@ -307,3 +356,43 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) { · 113 unmodified lines
307356 return "", false
308357 }
309358 }
359+
360+func convertClaudeToolResultContentToString(content gjson.Result) string {
361+ if !content.Exists() {
362+ return ""
363+ }
364+
365+ if content.Type == gjson.String {
366+ return content.String()
367+ }
368+
369+ if content.IsArray() {
370+ var parts []string
371+ content.ForEach(func(_, item gjson.Result) bool {
372+ switch {
373+ case item.Type == gjson.String:
374+ parts = append(parts, item.String())
375+ case item.IsObject() && item.Get("text").Exists() && item.Get("text").Type == gjson.String:
376+ parts = append(parts, item.Get("text").String())
377+ default:
378+ parts = append(parts, item.Raw)
379+ }
380+ return true
381+ })
382+
383+ joined := strings.Join(parts, "\n\n")
384+ if strings.TrimSpace(joined) != "" {
385+ return joined
386+ }
387+ return content.Raw
388+ }
389+
390+ if content.IsObject() {
391+ if text := content.Get("text"); text.Exists() && text.Type == gjson.String {
392+ return text.String()
393+ }
394+ return content.Raw
395+ }
396+
397+ return content.Raw
398+}
internal/translator/openai/claude/openai_claude_request_test.go+500−0
@@ -0,0 +1,500 @@
1+package claude
2+
3+import (
4+ "testing"
5+
6+ "github.com/tidwall/gjson"
7+)
8+
9+// TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent tests the mapping
10+// of Claude thinking content to OpenAI reasoning_content field.
11+func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ inputJSON string
15+ wantReasoningContent string
16+ wantHasReasoningContent bool
17+ wantContentText string // Expected visible content text (if any)
18+ wantHasContent bool
19+ }{
20+ {
21+ name: "AC1: assistant message with thinking and text",
22+ inputJSON: `{
23+ "model": "claude-3-opus",
24+ "messages": [{
25+ "role": "assistant",
26+ "content": [
27+ {"type": "thinking", "thinking": "Let me analyze this step by step..."},
28+ {"type": "text", "text": "Here is my response."}
29+ ]
30+ }]
31+ }`,
32+ wantReasoningContent: "Let me analyze this step by step...",
33+ wantHasReasoningContent: true,
34+ wantContentText: "Here is my response.",
35+ wantHasContent: true,
36+ },
37+ {
38+ name: "AC2: redacted_thinking must be ignored",
39+ inputJSON: `{
40+ "model": "claude-3-opus",
41+ "messages": [{
42+ "role": "assistant",
43+ "content": [
44+ {"type": "redacted_thinking", "data": "secret"},
45+ {"type": "text", "text": "Visible response."}
46+ ]
47+ }]
48+ }`,
49+ wantReasoningContent: "",
50+ wantHasReasoningContent: false,
51+ wantContentText: "Visible response.",
52+ wantHasContent: true,
53+ },
54+ {
55+ name: "AC3: thinking-only message preserved with reasoning_content",
56+ inputJSON: `{
57+ "model": "claude-3-opus",
58+ "messages": [{
59+ "role": "assistant",
60+ "content": [
61+ {"type": "thinking", "thinking": "Internal reasoning only."}
62+ ]
63+ }]
64+ }`,
65+ wantReasoningContent: "Internal reasoning only.",
66+ wantHasReasoningContent: true,
67+ wantContentText: "",
68+ // For OpenAI compatibility, content field is set to empty string "" when no text content exists
69+ wantHasContent: false,
70+ },
71+ {
72+ name: "AC4: thinking in user role must be ignored",
73+ inputJSON: `{
74+ "model": "claude-3-opus",
75+ "messages": [{
76+ "role": "user",
77+ "content": [
78+ {"type": "thinking", "thinking": "Injected thinking"},
79+ {"type": "text", "text": "User message."}
80+ ]
81+ }]
82+ }`,
83+ wantReasoningContent: "",
84+ wantHasReasoningContent: false,
85+ wantContentText: "User message.",
86+ wantHasContent: true,
87+ },
88+ {
89+ name: "AC4: thinking in system role must be ignored",
90+ inputJSON: `{
91+ "model": "claude-3-opus",
92+ "system": [
93+ {"type": "thinking", "thinking": "Injected system thinking"},
94+ {"type": "text", "text": "System prompt."}
95+ ],
96+ "messages": [{
97+ "role": "user",
98+ "content": [{"type": "text", "text": "Hello"}]
99+ }]
100+ }`,
101+ // System messages don't have reasoning_content mapping
102+ wantReasoningContent: "",
103+ wantHasReasoningContent: false,
104+ wantContentText: "Hello",
105+ wantHasContent: true,
106+ },
107+ {
108+ name: "AC5: empty thinking must be ignored",
109+ inputJSON: `{
110+ "model": "claude-3-opus",
111+ "messages": [{
112+ "role": "assistant",
113+ "content": [
114+ {"type": "thinking", "thinking": ""},
115+ {"type": "text", "text": "Response with empty thinking."}
116+ ]
117+ }]
118+ }`,
119+ wantReasoningContent: "",
120+ wantHasReasoningContent: false,
121+ wantContentText: "Response with empty thinking.",
122+ wantHasContent: true,
123+ },
124+ {
125+ name: "AC5: whitespace-only thinking must be ignored",
126+ inputJSON: `{
127+ "model": "claude-3-opus",
128+ "messages": [{
129+ "role": "assistant",
130+ "content": [
131+ {"type": "thinking", "thinking": " \n\t "},
132+ {"type": "text", "text": "Response with whitespace thinking."}
133+ ]
134+ }]
135+ }`,
136+ wantReasoningContent: "",
137+ wantHasReasoningContent: false,
138+ wantContentText: "Response with whitespace thinking.",
139+ wantHasContent: true,
140+ },
141+ {
142+ name: "Multiple thinking parts concatenated",
143+ inputJSON: `{
144+ "model": "claude-3-opus",
145+ "messages": [{
146+ "role": "assistant",
147+ "content": [
148+ {"type": "thinking", "thinking": "First thought."},
149+ {"type": "thinking", "thinking": "Second thought."},
150+ {"type": "text", "text": "Final answer."}
151+ ]
152+ }]
153+ }`,
154+ wantReasoningContent: "First thought.\n\nSecond thought.",
155+ wantHasReasoningContent: true,
156+ wantContentText: "Final answer.",
157+ wantHasContent: true,
158+ },
159+ {
160+ name: "Mixed thinking and redacted_thinking",
161+ inputJSON: `{
162+ "model": "claude-3-opus",
163+ "messages": [{
164+ "role": "assistant",
165+ "content": [
166+ {"type": "thinking", "thinking": "Visible thought."},
167+ {"type": "redacted_thinking", "data": "hidden"},
168+ {"type": "text", "text": "Answer."}
169+ ]
170+ }]
171+ }`,
172+ wantReasoningContent: "Visible thought.",
173+ wantHasReasoningContent: true,
174+ wantContentText: "Answer.",
175+ wantHasContent: true,
176+ },
177+ }
178+
179+ for _, tt := range tests {
180+ t.Run(tt.name, func(t *testing.T) {
181+ result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
182+ resultJSON := gjson.ParseBytes(result)
183+
184+ // Find the relevant message (skip system message at index 0)
185+ messages := resultJSON.Get("messages").Array()
186+ if len(messages) < 2 {
187+ if tt.wantHasReasoningContent || tt.wantHasContent {
188+ t.Fatalf("Expected at least 2 messages (system + user/assistant), got %d", len(messages))
189+ }
190+ return
191+ }
192+
193+ // Check the last non-system message
194+ var targetMsg gjson.Result
195+ for i := len(messages) - 1; i >= 0; i-- {
196+ if messages[i].Get("role").String() != "system" {
197+ targetMsg = messages[i]
198+ break
199+ }
200+ }
201+
202+ // Check reasoning_content
203+ gotReasoningContent := targetMsg.Get("reasoning_content").String()
204+ gotHasReasoningContent := targetMsg.Get("reasoning_content").Exists()
205+
206+ if gotHasReasoningContent != tt.wantHasReasoningContent {
207+ t.Errorf("reasoning_content existence = %v, want %v", gotHasReasoningContent, tt.wantHasReasoningContent)
208+ }
209+
210+ if gotReasoningContent != tt.wantReasoningContent {
211+ t.Errorf("reasoning_content = %q, want %q", gotReasoningContent, tt.wantReasoningContent)
212+ }
213+
214+ // Check content
215+ content := targetMsg.Get("content")
216+ // content has meaningful content if it's a non-empty array, or a non-empty string
217+ var gotHasContent bool
218+ switch {
219+ case content.IsArray():
220+ gotHasContent = len(content.Array()) > 0
221+ case content.Type == gjson.String:
222+ gotHasContent = content.String() != ""
223+ default:
224+ gotHasContent = false
225+ }
226+
227+ if gotHasContent != tt.wantHasContent {
228+ t.Errorf("content existence = %v, want %v", gotHasContent, tt.wantHasContent)
229+ }
230+
231+ if tt.wantHasContent && tt.wantContentText != "" {
232+ // Find text content
233+ var foundText string
234+ content.ForEach(func(_, v gjson.Result) bool {
235+ if v.Get("type").String() == "text" {
236+ foundText = v.Get("text").String()
237+ return false
238+ }
239+ return true
240+ })
241+ if foundText != tt.wantContentText {
242+ t.Errorf("content text = %q, want %q", foundText, tt.wantContentText)
243+ }
244+ }
245+ })
246+ }
247+}
248+
249+// TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved tests AC3:
250+// that a message with only thinking content is preserved (not dropped).
251+func TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T) {
252+ inputJSON := `{
253+ "model": "claude-3-opus",
254+ "messages": [
255+ {
256+ "role": "user",
257+ "content": [{"type": "text", "text": "What is 2+2?"}]
258+ },
259+ {
260+ "role": "assistant",
261+ "content": [{"type": "thinking", "thinking": "Let me calculate: 2+2=4"}]
262+ },
263+ {
264+ "role": "user",
265+ "content": [{"type": "text", "text": "Thanks"}]
266+ }
267+ ]
268+ }`
269+
270+ result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
271+ resultJSON := gjson.ParseBytes(result)
272+
273+ messages := resultJSON.Get("messages").Array()
274+
275+ // Should have: system (auto-added) + user + assistant (thinking-only) + user = 4 messages
276+ if len(messages) != 4 {
277+ t.Fatalf("Expected 4 messages, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw)
278+ }
279+
280+ // Check the assistant message (index 2) has reasoning_content
281+ assistantMsg := messages[2]
282+ if assistantMsg.Get("role").String() != "assistant" {
283+ t.Errorf("Expected message[2] to be assistant, got %s", assistantMsg.Get("role").String())
284+ }
285+
286+ if !assistantMsg.Get("reasoning_content").Exists() {
287+ t.Error("Expected assistant message to have reasoning_content")
288+ }
289+
290+ if assistantMsg.Get("reasoning_content").String() != "Let me calculate: 2+2=4" {
291+ t.Errorf("Unexpected reasoning_content: %s", assistantMsg.Get("reasoning_content").String())
292+ }
293+}
294+
295+func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) {
296+ inputJSON := `{
297+ "model": "claude-3-opus",
298+ "messages": [
299+ {
300+ "role": "assistant",
301+ "content": [
302+ {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
303+ ]
304+ },
305+ {
306+ "role": "user",
307+ "content": [
308+ {"type": "text", "text": "before"},
309+ {"type": "tool_result", "tool_use_id": "call_1", "content": [{"type":"text","text":"tool ok"}]},
310+ {"type": "text", "text": "after"}
311+ ]
312+ }
313+ ]
314+ }`
315+
316+ result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
317+ resultJSON := gjson.ParseBytes(result)
318+ messages := resultJSON.Get("messages").Array()
319+
320+ // OpenAI requires: tool messages MUST immediately follow assistant(tool_calls).
321+ // Correct order: system + assistant(tool_calls) + tool(result) + user(before+after)
322+ if len(messages) != 4 {
323+ t.Fatalf("Expected 4 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
324+ }
325+
326+ if messages[0].Get("role").String() != "system" {
327+ t.Fatalf("Expected messages[0] to be system, got %s", messages[0].Get("role").String())
328+ }
329+
330+ if messages[1].Get("role").String() != "assistant" || !messages[1].Get("tool_calls").Exists() {
331+ t.Fatalf("Expected messages[1] to be assistant tool_calls, got %s: %s", messages[1].Get("role").String(), messages[1].Raw)
332+ }
333+
334+ // tool message MUST immediately follow assistant(tool_calls) per OpenAI spec
335+ if messages[2].Get("role").String() != "tool" {
336+ t.Fatalf("Expected messages[2] to be tool (must follow tool_calls), got %s", messages[2].Get("role").String())
337+ }
338+ if got := messages[2].Get("tool_call_id").String(); got != "call_1" {
339+ t.Fatalf("Expected tool_call_id %q, got %q", "call_1", got)
340+ }
341+ if got := messages[2].Get("content").String(); got != "tool ok" {
342+ t.Fatalf("Expected tool content %q, got %q", "tool ok", got)
343+ }
344+
345+ // User message comes after tool message
346+ if messages[3].Get("role").String() != "user" {
347+ t.Fatalf("Expected messages[3] to be user, got %s", messages[3].Get("role").String())
348+ }
349+ // User message should contain both "before" and "after" text
350+ if got := messages[3].Get("content.0.text").String(); got != "before" {
351+ t.Fatalf("Expected user text[0] %q, got %q", "before", got)
352+ }
353+ if got := messages[3].Get("content.1.text").String(); got != "after" {
354+ t.Fatalf("Expected user text[1] %q, got %q", "after", got)
355+ }
356+}
357+
358+func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) {
359+ inputJSON := `{
360+ "model": "claude-3-opus",
361+ "messages": [
362+ {
363+ "role": "assistant",
364+ "content": [
365+ {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
366+ ]
367+ },
368+ {
369+ "role": "user",
370+ "content": [
371+ {"type": "tool_result", "tool_use_id": "call_1", "content": {"foo": "bar"}}
372+ ]
373+ }
374+ ]
375+ }`
376+
377+ result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
378+ resultJSON := gjson.ParseBytes(result)
379+ messages := resultJSON.Get("messages").Array()
380+
381+ // system + assistant(tool_calls) + tool(result)
382+ if len(messages) != 3 {
383+ t.Fatalf("Expected 3 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
384+ }
385+
386+ if messages[2].Get("role").String() != "tool" {
387+ t.Fatalf("Expected messages[2] to be tool, got %s", messages[2].Get("role").String())
388+ }
389+
390+ toolContent := messages[2].Get("content").String()
391+ parsed := gjson.Parse(toolContent)
392+ if parsed.Get("foo").String() != "bar" {
393+ t.Fatalf("Expected tool content JSON foo=bar, got %q", toolContent)
394+ }
395+}
396+
397+func TestConvertClaudeRequestToOpenAI_AssistantTextToolUseTextOrder(t *testing.T) {
398+ inputJSON := `{
399+ "model": "claude-3-opus",
400+ "messages": [
401+ {
402+ "role": "assistant",
403+ "content": [
404+ {"type": "text", "text": "pre"},
405+ {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}},
406+ {"type": "text", "text": "post"}
407+ ]
408+ }
409+ ]
410+ }`
411+
412+ result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
413+ resultJSON := gjson.ParseBytes(result)
414+ messages := resultJSON.Get("messages").Array()
415+
416+ // New behavior: content + tool_calls unified in single assistant message
417+ // Expect: system + assistant(content[pre,post] + tool_calls)
418+ if len(messages) != 2 {
419+ t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
420+ }
421+
422+ if messages[0].Get("role").String() != "system" {
423+ t.Fatalf("Expected messages[0] to be system, got %s", messages[0].Get("role").String())
424+ }
425+
426+ assistantMsg := messages[1]
427+ if assistantMsg.Get("role").String() != "assistant" {
428+ t.Fatalf("Expected messages[1] to be assistant, got %s", assistantMsg.Get("role").String())
429+ }
430+
431+ // Should have both content and tool_calls in same message
432+ if !assistantMsg.Get("tool_calls").Exists() {
433+ t.Fatalf("Expected assistant message to have tool_calls")
434+ }
435+ if got := assistantMsg.Get("tool_calls.0.id").String(); got != "call_1" {
436+ t.Fatalf("Expected tool_call id %q, got %q", "call_1", got)
437+ }
438+ if got := assistantMsg.Get("tool_calls.0.function.name").String(); got != "do_work" {
439+ t.Fatalf("Expected tool_call name %q, got %q", "do_work", got)
440+ }
441+
442+ // Content should have both pre and post text
443+ if got := assistantMsg.Get("content.0.text").String(); got != "pre" {
444+ t.Fatalf("Expected content[0] text %q, got %q", "pre", got)
445+ }
446+ if got := assistantMsg.Get("content.1.text").String(); got != "post" {
447+ t.Fatalf("Expected content[1] text %q, got %q", "post", got)
448+ }
449+}
450+
451+func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *testing.T) {
452+ inputJSON := `{
453+ "model": "claude-3-opus",
454+ "messages": [
455+ {
456+ "role": "assistant",
457+ "content": [
458+ {"type": "thinking", "thinking": "t1"},
459+ {"type": "text", "text": "pre"},
460+ {"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}},
461+ {"type": "thinking", "thinking": "t2"},
462+ {"type": "text", "text": "post"}
463+ ]
464+ }
465+ ]
466+ }`
467+
468+ result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
469+ resultJSON := gjson.ParseBytes(result)
470+ messages := resultJSON.Get("messages").Array()
471+
472+ // New behavior: all content, thinking, and tool_calls unified in single assistant message
473+ // Expect: system + assistant(content[pre,post] + tool_calls + reasoning_content[t1+t2])
474+ if len(messages) != 2 {
475+ t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
476+ }
477+
478+ assistantMsg := messages[1]
479+ if assistantMsg.Get("role").String() != "assistant" {
480+ t.Fatalf("Expected messages[1] to be assistant, got %s", assistantMsg.Get("role").String())
481+ }
482+
483+ // Should have content with both pre and post
484+ if got := assistantMsg.Get("content.0.text").String(); got != "pre" {
485+ t.Fatalf("Expected content[0] text %q, got %q", "pre", got)
486+ }
487+ if got := assistantMsg.Get("content.1.text").String(); got != "post" {
488+ t.Fatalf("Expected content[1] text %q, got %q", "post", got)
489+ }
490+
491+ // Should have tool_calls
492+ if !assistantMsg.Get("tool_calls").Exists() {
493+ t.Fatalf("Expected assistant message to have tool_calls")
494+ }
495+
496+ // Should have combined reasoning_content from both thinking blocks
497+ if got := assistantMsg.Get("reasoning_content").String(); got != "t1\n\nt2" {
498+ t.Fatalf("Expected reasoning_content %q, got %q", "t1\n\nt2", got)
499+ }
500+}
internal/translator/openai/claude/openai_claude_response.go+4−4
@@ -480,15 +480,15 @@ func collectOpenAIReasoningTexts(node gjson.Result) []string { · 479 unmodified lines
480480
481481 switch node.Type {
482482 case gjson.String:
483− if text := strings.TrimSpace(node.String()); text != "" {
483+ if text := node.String(); text != "" {
484484 texts = append(texts, text)
485485 }
486486 case gjson.JSON:
487487 if text := node.Get("text"); text.Exists() {
488− if trimmed := strings.TrimSpace(text.String()); trimmed != "" {
489− texts = append(texts, trimmed)
488+ if textStr := text.String(); textStr != "" {
489+ texts = append(texts, textStr)
490490 }
491− } else if raw := strings.TrimSpace(node.Raw); raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
491+ } else if raw := node.Raw; raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
492492 texts = append(texts, raw)
493493 }
494494 }
internal/util/gemini_schema.go+5−0
@@ -390,6 +390,11 @@ func addEmptySchemaPlaceholder(jsonStr string) string { · 389 unmodified lines
390390
391391 // If schema has properties but none are required, add a minimal placeholder.
392392 if propsVal.IsObject() && !hasRequiredProperties {
393+ // DO NOT add placeholder if it's a top-level schema (parentPath is empty)
394+ // or if we've already added a placeholder reason above.
395+ if parentPath == "" {
396+ continue
397+ }
393398 placeholderPath := joinPath(propsPath, "_")
394399 if !gjson.Get(jsonStr, placeholderPath).Exists() {
395400 jsonStr, _ = sjson.Set(jsonStr, placeholderPath+".type", "boolean")
internal/util/gemini_schema_test.go+3−1
@@ -127,8 +127,10 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing · 126 unmodified lines
127127 "type": "object",
128128 "description": "Accepts: null | object",
129129 "properties": {
130+ "_": { "type": "boolean" },
130131 "kind": { "type": "string" }
131− }
132+ },
133+ "required": ["_"]
132134 }
133135 }
134136 }`
internal/util/gemini_thinking.go+55−17
@@ -288,37 +288,73 @@ func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte { · 287 unmodified lines
288288
289289 // ApplyGemini3ThinkingLevelFromMetadata applies thinkingLevel from metadata for Gemini 3 models.
290290 // For standard Gemini API format (generationConfig.thinkingConfig path).
291−// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)).
291+// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal))
292+// or numeric budget suffix (e.g., model(1000)) which gets converted to a thinkingLevel.
292293 func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any, body []byte) []byte {
293− if !IsGemini3Model(model) {
294+ // Use the alias from metadata if available for model type detection
295+ lookupModel := ResolveOriginalModel(model, metadata)
296+ if !IsGemini3Model(lookupModel) && !IsGemini3Model(model) {
294297 return body
295298 }
299+
300+ // Determine which model to use for validation
301+ checkModel := model
302+ if IsGemini3Model(lookupModel) {
303+ checkModel = lookupModel
304+ }
305+
306+ // First try to get effort string from metadata
296307 effort, ok := ReasoningEffortFromMetadata(metadata)
297− if !ok || effort == "" {
298− return body
308+ if ok && effort != "" {
309+ if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid {
310+ return ApplyGeminiThinkingLevel(body, level, nil)
311+ }
299312 }
300− // Validate and apply the thinkingLevel
301− if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid {
302− return ApplyGeminiThinkingLevel(body, level, nil)
313+
314+ // Fallback: check for numeric budget and convert to thinkingLevel
315+ budget, _, _, matched := ThinkingFromMetadata(metadata)
316+ if matched && budget != nil {
317+ if level, valid := ThinkingBudgetToGemini3Level(checkModel, *budget); valid {
318+ return ApplyGeminiThinkingLevel(body, level, nil)
319+ }
303320 }
321+
304322 return body
305323 }
306324
307325 // ApplyGemini3ThinkingLevelFromMetadataCLI applies thinkingLevel from metadata for Gemini 3 models.
308326 // For Gemini CLI API format (request.generationConfig.thinkingConfig path).
309−// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)).
327+// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal))
328+// or numeric budget suffix (e.g., model(1000)) which gets converted to a thinkingLevel.
310329 func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string]any, body []byte) []byte {
311− if !IsGemini3Model(model) {
330+ // Use the alias from metadata if available for model type detection
331+ lookupModel := ResolveOriginalModel(model, metadata)
332+ if !IsGemini3Model(lookupModel) && !IsGemini3Model(model) {
312333 return body
313334 }
335+
336+ // Determine which model to use for validation
337+ checkModel := model
338+ if IsGemini3Model(lookupModel) {
339+ checkModel = lookupModel
340+ }
341+
342+ // First try to get effort string from metadata
314343 effort, ok := ReasoningEffortFromMetadata(metadata)
315− if !ok || effort == "" {
316− return body
344+ if ok && effort != "" {
345+ if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid {
346+ return ApplyGeminiCLIThinkingLevel(body, level, nil)
347+ }
317348 }
318− // Validate and apply the thinkingLevel
319− if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid {
320− return ApplyGeminiCLIThinkingLevel(body, level, nil)
349+
350+ // Fallback: check for numeric budget and convert to thinkingLevel
351+ budget, _, _, matched := ThinkingFromMetadata(metadata)
352+ if matched && budget != nil {
353+ if level, valid := ThinkingBudgetToGemini3Level(checkModel, *budget); valid {
354+ return ApplyGeminiCLIThinkingLevel(body, level, nil)
355+ }
321356 }
357+
322358 return body
323359 }
324360
@@ -326,15 +362,17 @@ func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string] · 1 unmodified line
326362 // For Gemini CLI API format (request.generationConfig.thinkingConfig path).
327363 // Returns the modified body if thinkingConfig was added, otherwise returns the original.
328364 // For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation.
329−func ApplyDefaultThinkingIfNeededCLI(model string, body []byte) []byte {
330− if !ModelHasDefaultThinking(model) {
365+func ApplyDefaultThinkingIfNeededCLI(model string, metadata map[string]any, body []byte) []byte {
366+ // Use the alias from metadata if available for model property lookup
367+ lookupModel := ResolveOriginalModel(model, metadata)
368+ if !ModelHasDefaultThinking(lookupModel) && !ModelHasDefaultThinking(model) {
331369 return body
332370 }
333371 if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() {
334372 return body
335373 }
336374 // Gemini 3 models use thinkingLevel instead of thinkingBudget
337− if IsGemini3Model(model) {
375+ if IsGemini3Model(lookupModel) || IsGemini3Model(model) {
338376 // Don't set a default - let the API use its dynamic default ("high")
339377 // Only set includeThoughts
340378 updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true)
internal/util/sanitize_test.go+56−0
@@ -0,0 +1,56 @@
1+package util
2+
3+import (
4+ "testing"
5+)
6+
7+func TestSanitizeFunctionName(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ input string
11+ expected string
12+ }{
13+ {"Normal", "valid_name", "valid_name"},
14+ {"With Dots", "name.with.dots", "name.with.dots"},
15+ {"With Colons", "name:with:colons", "name:with:colons"},
16+ {"With Dashes", "name-with-dashes", "name-with-dashes"},
17+ {"Mixed Allowed", "name.with_dots:colons-dashes", "name.with_dots:colons-dashes"},
18+ {"Invalid Characters", "name!with@invalid#chars", "name_with_invalid_chars"},
19+ {"Spaces", "name with spaces", "name_with_spaces"},
20+ {"Non-ASCII", "name_with_你好_chars", "name_with____chars"},
21+ {"Starts with digit", "123name", "_123name"},
22+ {"Starts with dot", ".name", "_.name"},
23+ {"Starts with colon", ":name", "_:name"},
24+ {"Starts with dash", "-name", "_-name"},
25+ {"Starts with invalid char", "!name", "_name"},
26+ {"Exactly 64 chars", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"},
27+ {"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"},
28+ {"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"},
29+ {"Starts with digit (64 chars total)", "1234567890123456789012345678901234567890123456789012345678901234", "_123456789012345678901234567890123456789012345678901234567890123"},
30+ {"Starts with invalid char (64 chars total)", "!234567890123456789012345678901234567890123456789012345678901234", "_234567890123456789012345678901234567890123456789012345678901234"},
31+ {"Empty", "", ""},
32+ {"Single character invalid", "@", "_"},
33+ {"Single character valid", "a", "a"},
34+ {"Single character digit", "1", "_1"},
35+ {"Single character underscore", "_", "_"},
36+ }
37+
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ got := SanitizeFunctionName(tt.input)
41+ if got != tt.expected {
42+ t.Errorf("SanitizeFunctionName(%q) = %v, want %v", tt.input, got, tt.expected)
43+ }
44+ // Verify Gemini compliance
45+ if len(got) > 64 {
46+ t.Errorf("SanitizeFunctionName(%q) result too long: %d", tt.input, len(got))
47+ }
48+ if len(got) > 0 {
49+ first := got[0]
50+ if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {
51+ t.Errorf("SanitizeFunctionName(%q) result starts with invalid char: %c", tt.input, first)
52+ }
53+ }
54+ })
55+ }
56+}
internal/util/thinking.go+21−4
@@ -12,9 +12,18 @@ func ModelSupportsThinking(model string) bool { · 11 unmodified lines
1212 if model == "" {
1313 return false
1414 }
15+ // First check the global dynamic registry
1516 if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil {
1617 return info.Thinking != nil
1718 }
19+ // Fallback: check static model definitions
20+ if info := registry.LookupStaticModelInfo(model); info != nil {
21+ return info.Thinking != nil
22+ }
23+ // Fallback: check Antigravity static config
24+ if cfg := registry.GetAntigravityModelConfig()[model]; cfg != nil {
25+ return cfg.Thinking != nil
26+ }
1827 return false
1928 }
2029
@@ -63,11 +72,19 @@ func thinkingRangeFromRegistry(model string) (found bool, min int, max int, zero · 42 unmodified lines
6372 if model == "" {
6473 return false, 0, 0, false, false
6574 }
66− info := registry.GetGlobalRegistry().GetModelInfo(model)
67− if info == nil || info.Thinking == nil {
68− return false, 0, 0, false, false
75+ // First check global dynamic registry
76+ if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil && info.Thinking != nil {
77+ return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed
78+ }
79+ // Fallback: check static model definitions
80+ if info := registry.LookupStaticModelInfo(model); info != nil && info.Thinking != nil {
81+ return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed
82+ }
83+ // Fallback: check Antigravity static config
84+ if cfg := registry.GetAntigravityModelConfig()[model]; cfg != nil && cfg.Thinking != nil {
85+ return true, cfg.Thinking.Min, cfg.Thinking.Max, cfg.Thinking.ZeroAllowed, cfg.Thinking.DynamicAllowed
6986 }
70− return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed
87+ return false, 0, 0, false, false
7188 }
7289
7390 // GetModelThinkingLevels returns the discrete reasoning effort levels for the model.
internal/util/util.go+40−0
@@ -8,12 +8,52 @@ import ( · 7 unmodified lines
88 "io/fs"
99 "os"
1010 "path/filepath"
11+ "regexp"
1112 "strings"
1213
1314 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
1415 log "github.com/sirupsen/logrus"
1516 )
1617
18+var functionNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.:-]`)
19+
20+// SanitizeFunctionName ensures a function name matches the requirements for Gemini/Vertex AI.
21+// It replaces invalid characters with underscores, ensures it starts with a letter or underscore,
22+// and truncates it to 64 characters if necessary.
23+// Regex Rule: [^a-zA-Z0-9_.:-] replaced with _.
24+func SanitizeFunctionName(name string) string {
25+ if name == "" {
26+ return ""
27+ }
28+
29+ // Replace invalid characters with underscore
30+ sanitized := functionNameSanitizer.ReplaceAllString(name, "_")
31+
32+ // Ensure it starts with a letter or underscore
33+ // Re-reading requirements: Must start with a letter or an underscore.
34+ if len(sanitized) > 0 {
35+ first := sanitized[0]
36+ if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {
37+ // If it starts with an allowed character but not allowed at the beginning (digit, dot, colon, dash),
38+ // we must prepend an underscore.
39+
40+ // To stay within the 64-character limit while prepending, we must truncate first.
41+ if len(sanitized) >= 64 {
42+ sanitized = sanitized[:63]
43+ }
44+ sanitized = "_" + sanitized
45+ }
46+ } else {
47+ sanitized = "_"
48+ }
49+
50+ // Truncate to 64 characters
51+ if len(sanitized) > 64 {
52+ sanitized = sanitized[:64]
53+ }
54+ return sanitized
55+}
56+
1757 // SetLogLevel configures the logrus log level based on the configuration.
1858 // It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel.
1959 func SetLogLevel(cfg *config.Config) {
sdk/cliproxy/auth/model_name_mappings.go+3−1
@@ -81,7 +81,9 @@ func (m *Manager) applyOAuthModelMapping(auth *Auth, requestedModel string, meta · 80 unmodified lines
8181 out[k] = v
8282 }
8383 }
84− out[util.ModelMappingOriginalModelMetadataKey] = upstreamModel
84+ // Store the requested alias (e.g., "gp") so downstream can use it to look up
85+ // model metadata from the global registry where it was registered under this alias.
86+ out[util.ModelMappingOriginalModelMetadataKey] = requestedModel
8587 return upstreamModel, out
8688 }
8789
sdk/cliproxy/model_registry.go+1−0
@@ -13,6 +13,7 @@ type ModelRegistry interface { · 12 unmodified lines
1313 ClearModelQuotaExceeded(clientID, modelID string)
1414 ClientSupportsModel(clientID, modelID string) bool
1515 GetAvailableModels(handlerType string) []map[string]any
16+ GetAvailableModelsByProvider(provider string) []*ModelInfo
1617 }
1718
1819 // GlobalModelRegistry returns the shared registry instance.
test/model_alias_thinking_suffix_test.go+211−0
@@ -0,0 +1,211 @@
1+package test
2+
3+import (
4+ "testing"
5+
6+ "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
7+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
8+ "github.com/tidwall/gjson"
9+)
10+
11+// TestModelAliasThinkingSuffix tests the 32 test cases defined in docs/thinking_suffix_test_cases.md
12+// These tests verify the thinking suffix parsing and application logic across different providers.
13+func TestModelAliasThinkingSuffix(t *testing.T) {
14+ tests := []struct {
15+ id int
16+ name string
17+ provider string
18+ requestModel string
19+ suffixType string
20+ expectedField string // "thinkingBudget", "thinkingLevel", "budget_tokens", "reasoning_effort", "enable_thinking"
21+ expectedValue any
22+ upstreamModel string // The upstream model after alias resolution
23+ isAlias bool
24+ }{
25+ // === 1. Antigravity Provider ===
26+ // 1.1 Budget-only models (Gemini 2.5)
27+ {1, "antigravity_original_numeric", "antigravity", "gemini-2.5-computer-use-preview-10-2025(1000)", "numeric", "thinkingBudget", 1000, "gemini-2.5-computer-use-preview-10-2025", false},
28+ {2, "antigravity_alias_numeric", "antigravity", "gp(1000)", "numeric", "thinkingBudget", 1000, "gemini-2.5-computer-use-preview-10-2025", true},
29+ // 1.2 Budget+Levels models (Gemini 3)
30+ {3, "antigravity_original_numeric_to_level", "antigravity", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false},
31+ {4, "antigravity_original_level", "antigravity", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false},
32+ {5, "antigravity_alias_numeric_to_level", "antigravity", "gf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true},
33+ {6, "antigravity_alias_level", "antigravity", "gf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true},
34+
35+ // === 2. Gemini CLI Provider ===
36+ // 2.1 Budget-only models
37+ {7, "gemini_cli_original_numeric", "gemini-cli", "gemini-2.5-pro(8192)", "numeric", "thinkingBudget", 8192, "gemini-2.5-pro", false},
38+ {8, "gemini_cli_alias_numeric", "gemini-cli", "g25p(8192)", "numeric", "thinkingBudget", 8192, "gemini-2.5-pro", true},
39+ // 2.2 Budget+Levels models
40+ {9, "gemini_cli_original_numeric_to_level", "gemini-cli", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false},
41+ {10, "gemini_cli_original_level", "gemini-cli", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false},
42+ {11, "gemini_cli_alias_numeric_to_level", "gemini-cli", "gf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true},
43+ {12, "gemini_cli_alias_level", "gemini-cli", "gf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true},
44+
45+ // === 3. Vertex Provider ===
46+ // 3.1 Budget-only models
47+ {13, "vertex_original_numeric", "vertex", "gemini-2.5-pro(16384)", "numeric", "thinkingBudget", 16384, "gemini-2.5-pro", false},
48+ {14, "vertex_alias_numeric", "vertex", "vg25p(16384)", "numeric", "thinkingBudget", 16384, "gemini-2.5-pro", true},
49+ // 3.2 Budget+Levels models
50+ {15, "vertex_original_numeric_to_level", "vertex", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false},
51+ {16, "vertex_original_level", "vertex", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false},
52+ {17, "vertex_alias_numeric_to_level", "vertex", "vgf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true},
53+ {18, "vertex_alias_level", "vertex", "vgf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true},
54+
55+ // === 4. AI Studio Provider ===
56+ // 4.1 Budget-only models
57+ {19, "aistudio_original_numeric", "aistudio", "gemini-2.5-pro(12000)", "numeric", "thinkingBudget", 12000, "gemini-2.5-pro", false},
58+ {20, "aistudio_alias_numeric", "aistudio", "ag25p(12000)", "numeric", "thinkingBudget", 12000, "gemini-2.5-pro", true},
59+ // 4.2 Budget+Levels models
60+ {21, "aistudio_original_numeric_to_level", "aistudio", "gemini-3-flash-preview(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", false},
61+ {22, "aistudio_original_level", "aistudio", "gemini-3-flash-preview(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", false},
62+ {23, "aistudio_alias_numeric_to_level", "aistudio", "agf(1000)", "numeric", "thinkingLevel", "low", "gemini-3-flash-preview", true},
63+ {24, "aistudio_alias_level", "aistudio", "agf(low)", "level", "thinkingLevel", "low", "gemini-3-flash-preview", true},
64+
65+ // === 5. Claude Provider ===
66+ {25, "claude_original_numeric", "claude", "claude-sonnet-4-5-20250929(16384)", "numeric", "budget_tokens", 16384, "claude-sonnet-4-5-20250929", false},
67+ {26, "claude_alias_numeric", "claude", "cs45(16384)", "numeric", "budget_tokens", 16384, "claude-sonnet-4-5-20250929", true},
68+
69+ // === 6. Codex Provider ===
70+ {27, "codex_original_level", "codex", "gpt-5(high)", "level", "reasoning_effort", "high", "gpt-5", false},
71+ {28, "codex_alias_level", "codex", "g5(high)", "level", "reasoning_effort", "high", "gpt-5", true},
72+
73+ // === 7. Qwen Provider ===
74+ {29, "qwen_original_level", "qwen", "qwen3-coder-plus(high)", "level", "enable_thinking", true, "qwen3-coder-plus", false},
75+ {30, "qwen_alias_level", "qwen", "qcp(high)", "level", "enable_thinking", true, "qwen3-coder-plus", true},
76+
77+ // === 8. iFlow Provider ===
78+ {31, "iflow_original_level", "iflow", "glm-4.7(high)", "level", "reasoning_effort", "high", "glm-4.7", false},
79+ {32, "iflow_alias_level", "iflow", "glm(high)", "level", "reasoning_effort", "high", "glm-4.7", true},
80+ }
81+
82+ for _, tt := range tests {
83+ t.Run(tt.name, func(t *testing.T) {
84+ // Step 1: Parse model suffix (simulates SDK layer normalization)
85+ // For "gp(1000)" -> requestedModel="gp", metadata={thinking_budget: 1000}
86+ requestedModel, metadata := util.NormalizeThinkingModel(tt.requestModel)
87+
88+ // Verify suffix was parsed
89+ if metadata == nil && (tt.suffixType == "numeric" || tt.suffixType == "level") {
90+ t.Errorf("Case #%d: NormalizeThinkingModel(%q) metadata is nil", tt.id, tt.requestModel)
91+ return
92+ }
93+
94+ // Step 2: Simulate OAuth model mapping
95+ // Real flow: applyOAuthModelMapping stores requestedModel (the alias) in metadata
96+ if tt.isAlias {
97+ if metadata == nil {
98+ metadata = make(map[string]any)
99+ }
100+ metadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel
101+ }
102+
103+ // Step 3: Verify metadata extraction
104+ switch tt.suffixType {
105+ case "numeric":
106+ budget, _, _, matched := util.ThinkingFromMetadata(metadata)
107+ if !matched {
108+ t.Errorf("Case #%d: ThinkingFromMetadata did not match", tt.id)
109+ return
110+ }
111+ if budget == nil {
112+ t.Errorf("Case #%d: expected budget in metadata", tt.id)
113+ return
114+ }
115+ // For thinkingBudget/budget_tokens, verify the parsed budget value
116+ if tt.expectedField == "thinkingBudget" || tt.expectedField == "budget_tokens" {
117+ expectedBudget := tt.expectedValue.(int)
118+ if *budget != expectedBudget {
119+ t.Errorf("Case #%d: budget = %d, want %d", tt.id, *budget, expectedBudget)
120+ }
121+ }
122+ // For thinkingLevel (Gemini 3), verify conversion from budget to level
123+ if tt.expectedField == "thinkingLevel" {
124+ level, ok := util.ThinkingBudgetToGemini3Level(tt.upstreamModel, *budget)
125+ if !ok {
126+ t.Errorf("Case #%d: ThinkingBudgetToGemini3Level failed", tt.id)
127+ return
128+ }
129+ expectedLevel := tt.expectedValue.(string)
130+ if level != expectedLevel {
131+ t.Errorf("Case #%d: converted level = %q, want %q", tt.id, level, expectedLevel)
132+ }
133+ }
134+
135+ case "level":
136+ _, _, effort, matched := util.ThinkingFromMetadata(metadata)
137+ if !matched {
138+ t.Errorf("Case #%d: ThinkingFromMetadata did not match", tt.id)
139+ return
140+ }
141+ if effort == nil {
142+ t.Errorf("Case #%d: expected effort in metadata", tt.id)
143+ return
144+ }
145+ if tt.expectedField == "thinkingLevel" || tt.expectedField == "reasoning_effort" {
146+ expectedEffort := tt.expectedValue.(string)
147+ if *effort != expectedEffort {
148+ t.Errorf("Case #%d: effort = %q, want %q", tt.id, *effort, expectedEffort)
149+ }
150+ }
151+ }
152+
153+ // Step 4: Test Gemini-specific thinkingLevel conversion for Gemini 3 models
154+ if tt.expectedField == "thinkingLevel" && util.IsGemini3Model(tt.upstreamModel) {
155+ body := []byte(`{"request":{"contents":[]}}`)
156+
157+ // Build metadata simulating real OAuth flow:
158+ // - requestedModel (alias like "gf") is stored in model_mapping_original_model
159+ // - upstreamModel is passed as the model parameter
160+ testMetadata := make(map[string]any)
161+ if tt.isAlias {
162+ // Real flow: applyOAuthModelMapping stores requestedModel (the alias)
163+ testMetadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel
164+ }
165+ // Copy parsed metadata (thinking_budget, reasoning_effort, etc.)
166+ for k, v := range metadata {
167+ testMetadata[k] = v
168+ }
169+
170+ result := util.ApplyGemini3ThinkingLevelFromMetadataCLI(tt.upstreamModel, testMetadata, body)
171+ levelVal := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel")
172+
173+ expectedLevel := tt.expectedValue.(string)
174+ if !levelVal.Exists() {
175+ t.Errorf("Case #%d: expected thinkingLevel in result", tt.id)
176+ } else if levelVal.String() != expectedLevel {
177+ t.Errorf("Case #%d: thinkingLevel = %q, want %q", tt.id, levelVal.String(), expectedLevel)
178+ }
179+ }
180+
181+ // Step 5: Test Gemini 2.5 thinkingBudget application using real ApplyThinkingMetadataCLI flow
182+ if tt.expectedField == "thinkingBudget" && util.IsGemini25Model(tt.upstreamModel) {
183+ body := []byte(`{"request":{"contents":[]}}`)
184+
185+ // Build metadata simulating real OAuth flow:
186+ // - requestedModel (alias like "gp") is stored in model_mapping_original_model
187+ // - upstreamModel is passed as the model parameter
188+ testMetadata := make(map[string]any)
189+ if tt.isAlias {
190+ // Real flow: applyOAuthModelMapping stores requestedModel (the alias)
191+ testMetadata[util.ModelMappingOriginalModelMetadataKey] = requestedModel
192+ }
193+ // Copy parsed metadata (thinking_budget, reasoning_effort, etc.)
194+ for k, v := range metadata {
195+ testMetadata[k] = v
196+ }
197+
198+ // Use the exported ApplyThinkingMetadataCLI which includes the fallback logic
199+ result := executor.ApplyThinkingMetadataCLI(body, testMetadata, tt.upstreamModel)
200+ budgetVal := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget")
201+
202+ expectedBudget := tt.expectedValue.(int)
203+ if !budgetVal.Exists() {
204+ t.Errorf("Case #%d: expected thinkingBudget in result", tt.id)
205+ } else if int(budgetVal.Int()) != expectedBudget {
206+ t.Errorf("Case #%d: thinkingBudget = %d, want %d", tt.id, int(budgetVal.Int()), expectedBudget)
207+ }
208+ }
209+ })
210+ }
211+}