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
740 {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},740 {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},
741 {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},741 {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},
742 {ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},742 {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},
744 {ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},744 {ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
745 }745 }
746 models := make([]*ModelInfo, 0, len(entries))746 models := make([]*ModelInfo, 0, len(entries))
@@ -773,7 +773,7 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { · 26 unmodified lines
773 return map[string]*AntigravityModelConfig{773 return map[string]*AntigravityModelConfig{
774 "gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"},774 "gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"},
775 "gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash-lite"},775 "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"},
777 "gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-preview"},777 "gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-preview"},
778 "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"},778 "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"},
779 "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"},779 "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
625 return models625 return models
626}626}
627 627
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
634func (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
628// GetModelCount returns the number of available clients for a specific model753// GetModelCount returns the number of available clients for a specific model
629// Parameters:754// Parameters:
630// - modelID: The model ID to check755// - 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
96 to := sdktranslator.FromString("antigravity")96 to := sdktranslator.FromString("antigravity")
97 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)97 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
98 98
99 translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)99 translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
100 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)100 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
101 translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)101 translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
102 translated = normalizeAntigravityThinking(req.Model, translated, isClaude)102 translated = normalizeAntigravityThinking(req.Model, translated, isClaude)
103 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)103 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
104 104
@@ -191,9 +191,9 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * · 86 unmodified lines
191 to := sdktranslator.FromString("antigravity")191 to := sdktranslator.FromString("antigravity")
192 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)192 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
193 193
194 translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)194 translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
195 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)195 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
196 translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)196 translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
197 translated = normalizeAntigravityThinking(req.Model, translated, true)197 translated = normalizeAntigravityThinking(req.Model, translated, true)
198 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)198 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
199 199
@@ -527,9 +527,9 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya · 327 unmodified lines
527 to := sdktranslator.FromString("antigravity")527 to := sdktranslator.FromString("antigravity")
528 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)528 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
529 529
530 translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)530 translated = ApplyThinkingMetadataCLI(translated, req.Metadata, req.Model)
531 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)531 translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
532 translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)532 translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, translated)
533 translated = normalizeAntigravityThinking(req.Model, translated, isClaude)533 translated = normalizeAntigravityThinking(req.Model, translated, isClaude)
534 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)534 translated = applyPayloadConfigWithRoot(e.cfg, req.Model, "antigravity", "request", translated)
535 535
@@ -697,8 +697,8 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut · 161 unmodified lines
697 697
698 for idx, baseURL := range baseURLs {698 for idx, baseURL := range baseURLs {
699 payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)699 payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
700 payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model)700 payload = ApplyThinkingMetadataCLI(payload, req.Metadata, req.Model)
701 payload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, payload)701 payload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, payload)
702 payload = normalizeAntigravityThinking(req.Model, payload, isClaude)702 payload = normalizeAntigravityThinking(req.Model, payload, isClaude)
703 payload = deleteJSONField(payload, "project")703 payload = deleteJSONField(payload, "project")
704 payload = deleteJSONField(payload, "model")704 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
78 from := opts.SourceFormat78 from := opts.SourceFormat
79 to := sdktranslator.FromString("gemini-cli")79 to := sdktranslator.FromString("gemini-cli")
80 basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)80 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)
82 basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)82 basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)
83 basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload)83 basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload)
84 basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)84 basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)
85 basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)85 basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
86 basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)86 basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
@@ -217,9 +217,9 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 130 unmodified lines
217 from := opts.SourceFormat217 from := opts.SourceFormat
218 to := sdktranslator.FromString("gemini-cli")218 to := sdktranslator.FromString("gemini-cli")
219 basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)219 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)
221 basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)221 basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)
222 basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload)222 basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, req.Metadata, basePayload)
223 basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)223 basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)
224 basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)224 basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
225 basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)225 basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
@@ -421,7 +421,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth. · 195 unmodified lines
421 // Gemini CLI endpoint when iterating fallback variants.421 // Gemini CLI endpoint when iterating fallback variants.
422 for _, attemptModel := range models {422 for _, attemptModel := range models {
423 payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false)423 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)
425 payload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, payload)425 payload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, payload)
426 payload = deleteJSONField(payload, "project")426 payload = deleteJSONField(payload, "project")
427 payload = deleteJSONField(payload, "model")427 payload = deleteJSONField(payload, "model")
internal/runtime/executor/iflow_executor.go+24−37
@@ -441,21 +441,18 @@ func ensureToolsArray(body []byte) []byte { · 440 unmodified lines
441 return updated441 return updated
442}442}
443 443
444// preserveReasoningContentInMessages ensures reasoning_content from assistant messages in the444// preserveReasoningContentInMessages checks if reasoning_content from assistant messages
445// conversation history is preserved when sending to iFlow models that support thinking.445// is preserved in conversation history for iFlow models that support thinking.
446// This is critical for multi-turn conversations where the model needs to see its previous446// This is helpful for multi-turn conversations where the model may benefit from seeing
447// reasoning to maintain coherent thought chains across tool calls and conversation turns.447// its previous reasoning to maintain coherent thought chains.
448//448//
449// For GLM-4.7 and MiniMax-M2.1, the full assistant response (including reasoning) must be449// For GLM-4.6/4.7 and MiniMax M2/M2.1, it is recommended to include the full assistant
450// appended back into message history before the next call.450// response (including reasoning_content) in message history for better context continuity.
451func preserveReasoningContentInMessages(body []byte) []byte {451func preserveReasoningContentInMessages(body []byte) []byte {
452 model := strings.ToLower(gjson.GetBytes(body, "model").String())452 model := strings.ToLower(gjson.GetBytes(body, "model").String())
453 453
454 // Only apply to models that support thinking with history preservation454 // Only apply to models that support thinking with history preservation
455 needsPreservation := strings.HasPrefix(model, "glm-4.7") ||455 needsPreservation := strings.HasPrefix(model, "glm-4") || strings.HasPrefix(model, "minimax-m2")
456 strings.HasPrefix(model, "glm-4-7") ||
457 strings.HasPrefix(model, "minimax-m2.1") ||
458 strings.HasPrefix(model, "minimax-m2-1")
459 456
460 if !needsPreservation {457 if !needsPreservation {
461 return body458 return body
@@ -493,46 +490,36 @@ func preserveReasoningContentInMessages(body []byte) []byte { · 31 unmodified lines
493// This should be called after NormalizeThinkingConfig has processed the payload.490// This should be called after NormalizeThinkingConfig has processed the payload.
494//491//
495// Model-specific handling:492// Model-specific handling:
496// - GLM-4.7: Uses extra_body={"thinking": {"type": "enabled"}, "clear_thinking": false}493// - GLM-4.6/4.7: Uses chat_template_kwargs.enable_thinking (boolean) and chat_template_kwargs.clear_thinking=false
497// - MiniMax-M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation494// - MiniMax M2/M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation
498// - Other iFlow models: Uses chat_template_kwargs.enable_thinking (boolean)
499func applyIFlowThinkingConfig(body []byte) []byte {495func applyIFlowThinkingConfig(body []byte) []byte {
500 effort := gjson.GetBytes(body, "reasoning_effort")496 effort := gjson.GetBytes(body, "reasoning_effort")
501 model := strings.ToLower(gjson.GetBytes(body, "model").String())497 if !effort.Exists() {
502 498 return body
503 // Check if thinking should be enabled
504 val := ""
505 if effort.Exists() {
506 val = strings.ToLower(strings.TrimSpace(effort.String()))
507 }499 }
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 != ""
509 504
510 // Remove reasoning_effort as we'll convert to model-specific format505 // Remove reasoning_effort as we'll convert to model-specific format
511 if effort.Exists() {506 body, _ = sjson.DeleteBytes(body, "reasoning_effort")
512 body, _ = sjson.DeleteBytes(body, "reasoning_effort")507 body, _ = sjson.DeleteBytes(body, "thinking")
513 }
514 508
515 // GLM-4.7: Use extra_body with thinking config and clear_thinking: false509 // GLM-4.6/4.7: Use chat_template_kwargs
516 if strings.HasPrefix(model, "glm-4.7") || strings.HasPrefix(model, "glm-4-7") {510 if strings.HasPrefix(model, "glm-4") {
511 body, _ = sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
517 if enableThinking {512 if enableThinking {
518 body, _ = sjson.SetBytes(body, "extra_body.thinking.type", "enabled")513 body, _ = sjson.SetBytes(body, "chat_template_kwargs.clear_thinking", false)
519 body, _ = sjson.SetBytes(body, "extra_body.clear_thinking", false)
520 }514 }
521 return body515 return body
522 }516 }
523 517
524 // MiniMax-M2.1: Use reasoning_split=true for interleaved thinking518 // MiniMax M2/M2.1: Use reasoning_split
525 if strings.HasPrefix(model, "minimax-m2.1") || strings.HasPrefix(model, "minimax-m2-1") {519 if strings.HasPrefix(model, "minimax-m2") {
526 if enableThinking {520 body, _ = sjson.SetBytes(body, "reasoning_split", enableThinking)
527 body, _ = sjson.SetBytes(body, "reasoning_split", true)
528 }
529 return body521 return body
530 }522 }
531 523
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
537 return body524 return body
538}525}
internal/runtime/executor/payload_helpers.go+30−8
@@ -14,32 +14,54 @@ import ( · 13 unmodified lines
14// ApplyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192))14// ApplyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192))
15// for standard Gemini format payloads. It normalizes the budget when the model supports thinking.15// for standard Gemini format payloads. It normalizes the budget when the model supports thinking.
16func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte {16func 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)
18 if !ok || (budgetOverride == nil && includeOverride == nil) {29 if !ok || (budgetOverride == nil && includeOverride == nil) {
19 return payload30 return payload
20 }31 }
21 if !util.ModelSupportsThinking(model) {32 if !util.ModelSupportsThinking(thinkingModel) {
22 return payload33 return payload
23 }34 }
24 if budgetOverride != nil {35 if budgetOverride != nil {
25 norm := util.NormalizeThinkingBudget(model, *budgetOverride)36 norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride)
26 budgetOverride = &norm37 budgetOverride = &norm
27 }38 }
28 return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)39 return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
29}40}
30 41
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))
32// for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking.43// for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking.
33func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte {44func ApplyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte {
34 budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata)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)
35 if !ok || (budgetOverride == nil && includeOverride == nil) {57 if !ok || (budgetOverride == nil && includeOverride == nil) {
36 return payload58 return payload
37 }59 }
38 if !util.ModelSupportsThinking(model) {60 if !util.ModelSupportsThinking(thinkingModel) {
39 return payload61 return payload
40 }62 }
41 if budgetOverride != nil {63 if budgetOverride != nil {
42 norm := util.NormalizeThinkingBudget(model, *budgetOverride)64 norm := util.NormalizeThinkingBudget(thinkingModel, *budgetOverride)
43 budgetOverride = &norm65 budgetOverride = &norm
44 }66 }
45 return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride)67 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
118 // Handle content118 // Handle content
119 if contentResult.Exists() && contentResult.IsArray() {119 if contentResult.Exists() && contentResult.IsArray() {
120 var contentItems []string120 var contentItems []string
121 var reasoningParts []string // Accumulate thinking text for reasoning_content
121 var toolCalls []interface{}122 var toolCalls []interface{}
123 var toolResults []string // Collect tool_result messages to emit after the main message
122 124
123 contentResult.ForEach(func(_, part gjson.Result) bool {125 contentResult.ForEach(func(_, part gjson.Result) bool {
124 partType := part.Get("type").String()126 partType := part.Get("type").String()
125 127
126 switch partType {128 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
127 case "text", "image":143 case "text", "image":
128 if contentItem, ok := convertClaudeContentPart(part); ok {144 if contentItem, ok := convertClaudeContentPart(part); ok {
129 contentItems = append(contentItems, contentItem)145 contentItems = append(contentItems, contentItem)
130 }146 }
131 147
132 case "tool_use":148 case "tool_use":
133 // Convert to OpenAI tool call format149 // Only allow tool_use -> tool_calls for assistant messages (security: prevent injection).
134 toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}`150 if role == "assistant" {
135 toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String())151 toolCallJSON := `{"id":"","type":"function","function":{"name":"","arguments":""}}`
136 toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String())152 toolCallJSON, _ = sjson.Set(toolCallJSON, "id", part.Get("id").String())
137 153 toolCallJSON, _ = sjson.Set(toolCallJSON, "function.name", part.Get("name").String())
138 // Convert input to arguments JSON string154
139 if input := part.Get("input"); input.Exists() {155 // Convert input to arguments JSON string
140 toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw)156 if input := part.Get("input"); input.Exists() {
141 } else {157 toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", input.Raw)
142 toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}")158 } else {
159 toolCallJSON, _ = sjson.Set(toolCallJSON, "function.arguments", "{}")
160 }
161
162 toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value())
143 }163 }
144 164
145 toolCalls = append(toolCalls, gjson.Parse(toolCallJSON).Value())
146
147 case "tool_result":165 case "tool_result":
148 // Convert to OpenAI tool message format and add immediately to preserve order166 // Collect tool_result to emit after the main message (ensures tool results follow tool_calls)
149 toolResultJSON := `{"role":"tool","tool_call_id":"","content":""}`167 toolResultJSON := `{"role":"tool","tool_call_id":"","content":""}`
150 toolResultJSON, _ = sjson.Set(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String())168 toolResultJSON, _ = sjson.Set(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String())
151 toolResultJSON, _ = sjson.Set(toolResultJSON, "content", part.Get("content").String())169 toolResultJSON, _ = sjson.Set(toolResultJSON, "content", convertClaudeToolResultContentToString(part.Get("content")))
152 messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value())170 toolResults = append(toolResults, toolResultJSON)
153 }171 }
154 return true172 return true
155 })173 })
156 174
157 // Emit text/image content as one message175 // Build reasoning content string
158 if len(contentItems) > 0 {176 reasoningContent := ""
159 msgJSON := `{"role":"","content":""}`177 if len(reasoningParts) > 0 {
160 msgJSON, _ = sjson.Set(msgJSON, "role", role)178 reasoningContent = strings.Join(reasoningParts, "\n\n")
179 }
161 180
162 contentArrayJSON := "[]"181 hasContent := len(contentItems) > 0
163 for _, contentItem := range contentItems {182 hasReasoning := reasoningContent != ""
164 contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)183 hasToolCalls := len(toolCalls) > 0
165 }184 hasToolResults := len(toolResults) > 0
166 msgJSON, _ = sjson.SetRaw(msgJSON, "content", contentArrayJSON)185
167 186 // OpenAI requires: tool messages MUST immediately follow the assistant message with tool_calls.
168 contentValue := gjson.Get(msgJSON, "content")187 // Therefore, we emit tool_result messages FIRST (they respond to the previous assistant's tool_calls),
169 hasContent := false188 // then emit the current message's content.
170 switch {189 for _, toolResultJSON := range toolResults {
171 case !contentValue.Exists():190 messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolResultJSON).Value())
172 hasContent = false191 }
173 case contentValue.Type == gjson.String:192
174 hasContent = contentValue.String() != ""193 // For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content
175 case contentValue.IsArray():194 // This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency
176 hasContent = len(contentValue.Array()) > 0195 if role == "assistant" {
177 default:196 if hasContent || hasReasoning || hasToolCalls {
178 hasContent = contentValue.Raw != "" && contentValue.Raw != "null"197 msgJSON := `{"role":"assistant"}`
179 }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 }
180 220
181 if hasContent {
182 messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())221 messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(msgJSON).Value())
183 }222 }
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)
185 229
186 // Emit tool calls in a separate assistant message230 contentArrayJSON := "[]"
187 if role == "assistant" && len(toolCalls) > 0 {231 for _, contentItem := range contentItems {
188 toolCallMsgJSON := `{"role":"assistant","tool_calls":[]}`232 contentArrayJSON, _ = sjson.SetRaw(contentArrayJSON, "-1", contentItem)
189 toolCallMsgJSON, _ = sjson.Set(toolCallMsgJSON, "tool_calls", toolCalls)233 }
190 messagesJSON, _ = sjson.Set(messagesJSON, "-1", gjson.Parse(toolCallMsgJSON).Value())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 }
191 }240 }
192 241
193 } else if contentResult.Exists() && contentResult.Type == gjson.String {242 } else if contentResult.Exists() && contentResult.Type == gjson.String {
@@ -307,3 +356,43 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) { · 113 unmodified lines
307 return "", false356 return "", false
308 }357 }
309}358}
359
360func 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 @@
1package claude
2
3import (
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.
11func 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).
251func 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
295func 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
358func 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
397func 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
451func 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
480 480
481 switch node.Type {481 switch node.Type {
482 case gjson.String:482 case gjson.String:
483 if text := strings.TrimSpace(node.String()); text != "" {483 if text := node.String(); text != "" {
484 texts = append(texts, text)484 texts = append(texts, text)
485 }485 }
486 case gjson.JSON:486 case gjson.JSON:
487 if text := node.Get("text"); text.Exists() {487 if text := node.Get("text"); text.Exists() {
488 if trimmed := strings.TrimSpace(text.String()); trimmed != "" {488 if textStr := text.String(); textStr != "" {
489 texts = append(texts, trimmed)489 texts = append(texts, textStr)
490 }490 }
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, "[") {
492 texts = append(texts, raw)492 texts = append(texts, raw)
493 }493 }
494 }494 }
internal/util/gemini_schema.go+5−0
@@ -390,6 +390,11 @@ func addEmptySchemaPlaceholder(jsonStr string) string { · 389 unmodified lines
390 390
391 // If schema has properties but none are required, add a minimal placeholder.391 // If schema has properties but none are required, add a minimal placeholder.
392 if propsVal.IsObject() && !hasRequiredProperties {392 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 }
393 placeholderPath := joinPath(propsPath, "_")398 placeholderPath := joinPath(propsPath, "_")
394 if !gjson.Get(jsonStr, placeholderPath).Exists() {399 if !gjson.Get(jsonStr, placeholderPath).Exists() {
395 jsonStr, _ = sjson.Set(jsonStr, placeholderPath+".type", "boolean")400 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
127 "type": "object",127 "type": "object",
128 "description": "Accepts: null | object",128 "description": "Accepts: null | object",
129 "properties": {129 "properties": {
130 "_": { "type": "boolean" },
130 "kind": { "type": "string" }131 "kind": { "type": "string" }
131 }132 },
133 "required": ["_"]
132 }134 }
133 }135 }
134 }`136 }`
internal/util/gemini_thinking.go+55−17
@@ -288,37 +288,73 @@ func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte { · 287 unmodified lines
288 288
289// ApplyGemini3ThinkingLevelFromMetadata applies thinkingLevel from metadata for Gemini 3 models.289// ApplyGemini3ThinkingLevelFromMetadata applies thinkingLevel from metadata for Gemini 3 models.
290// For standard Gemini API format (generationConfig.thinkingConfig path).290// 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.
292func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any, body []byte) []byte {293func 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) {
294 return body297 return body
295 }298 }
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
296 effort, ok := ReasoningEffortFromMetadata(metadata)307 effort, ok := ReasoningEffortFromMetadata(metadata)
297 if !ok || effort == "" {308 if ok && effort != "" {
298 return body309 if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid {
310 return ApplyGeminiThinkingLevel(body, level, nil)
311 }
299 }312 }
300 // Validate and apply the thinkingLevel313
301 if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid {314 // Fallback: check for numeric budget and convert to thinkingLevel
302 return ApplyGeminiThinkingLevel(body, level, nil)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 }
303 }320 }
321
304 return body322 return body
305}323}
306 324
307// ApplyGemini3ThinkingLevelFromMetadataCLI applies thinkingLevel from metadata for Gemini 3 models.325// ApplyGemini3ThinkingLevelFromMetadataCLI applies thinkingLevel from metadata for Gemini 3 models.
308// For Gemini CLI API format (request.generationConfig.thinkingConfig path).326// 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.
310func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string]any, body []byte) []byte {329func 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) {
312 return body333 return body
313 }334 }
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
314 effort, ok := ReasoningEffortFromMetadata(metadata)343 effort, ok := ReasoningEffortFromMetadata(metadata)
315 if !ok || effort == "" {344 if ok && effort != "" {
316 return body345 if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid {
346 return ApplyGeminiCLIThinkingLevel(body, level, nil)
347 }
317 }348 }
318 // Validate and apply the thinkingLevel349
319 if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid {350 // Fallback: check for numeric budget and convert to thinkingLevel
320 return ApplyGeminiCLIThinkingLevel(body, level, nil)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 }
321 }356 }
357
322 return body358 return body
323}359}
324 360
@@ -326,15 +362,17 @@ func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string] · 1 unmodified line
326// For Gemini CLI API format (request.generationConfig.thinkingConfig path).362// For Gemini CLI API format (request.generationConfig.thinkingConfig path).
327// Returns the modified body if thinkingConfig was added, otherwise returns the original.363// Returns the modified body if thinkingConfig was added, otherwise returns the original.
328// For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation.364// For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation.
329func ApplyDefaultThinkingIfNeededCLI(model string, body []byte) []byte {365func ApplyDefaultThinkingIfNeededCLI(model string, metadata map[string]any, body []byte) []byte {
330 if !ModelHasDefaultThinking(model) {366 // Use the alias from metadata if available for model property lookup
367 lookupModel := ResolveOriginalModel(model, metadata)
368 if !ModelHasDefaultThinking(lookupModel) && !ModelHasDefaultThinking(model) {
331 return body369 return body
332 }370 }
333 if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() {371 if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() {
334 return body372 return body
335 }373 }
336 // Gemini 3 models use thinkingLevel instead of thinkingBudget374 // Gemini 3 models use thinkingLevel instead of thinkingBudget
337 if IsGemini3Model(model) {375 if IsGemini3Model(lookupModel) || IsGemini3Model(model) {
338 // Don't set a default - let the API use its dynamic default ("high")376 // Don't set a default - let the API use its dynamic default ("high")
339 // Only set includeThoughts377 // Only set includeThoughts
340 updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true)378 updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true)
internal/util/sanitize_test.go+56−0
@@ -0,0 +1,56 @@
1package util
2
3import (
4 "testing"
5)
6
7func 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
12 if model == "" {12 if model == "" {
13 return false13 return false
14 }14 }
15 // First check the global dynamic registry
15 if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil {16 if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil {
16 return info.Thinking != nil17 return info.Thinking != nil
17 }18 }
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 }
18 return false27 return false
19}28}
20 29
@@ -63,11 +72,19 @@ func thinkingRangeFromRegistry(model string) (found bool, min int, max int, zero · 42 unmodified lines
63 if model == "" {72 if model == "" {
64 return false, 0, 0, false, false73 return false, 0, 0, false, false
65 }74 }
66 info := registry.GetGlobalRegistry().GetModelInfo(model)75 // First check global dynamic registry
67 if info == nil || info.Thinking == nil {76 if info := registry.GetGlobalRegistry().GetModelInfo(model); info != nil && info.Thinking != nil {
68 return false, 0, 0, false, false77 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
69 }86 }
70 return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed87 return false, 0, 0, false, false
71}88}
72 89
73// GetModelThinkingLevels returns the discrete reasoning effort levels for the model.90// GetModelThinkingLevels returns the discrete reasoning effort levels for the model.
internal/util/util.go+40−0
@@ -8,12 +8,52 @@ import ( · 7 unmodified lines
8 "io/fs"8 "io/fs"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11 "regexp"
11 "strings"12 "strings"
12 13
13 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"14 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14 log "github.com/sirupsen/logrus"15 log "github.com/sirupsen/logrus"
15)16)
16 17
18var 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 _.
24func 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
17// SetLogLevel configures the logrus log level based on the configuration.57// SetLogLevel configures the logrus log level based on the configuration.
18// It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel.58// It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel.
19func SetLogLevel(cfg *config.Config) {59func 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
81 out[k] = v81 out[k] = v
82 }82 }
83 }83 }
84 out[util.ModelMappingOriginalModelMetadataKey] = upstreamModel84 // 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
85 return upstreamModel, out87 return upstreamModel, out
86}88}
87 89
sdk/cliproxy/model_registry.go+1−0
@@ -13,6 +13,7 @@ type ModelRegistry interface { · 12 unmodified lines
13 ClearModelQuotaExceeded(clientID, modelID string)13 ClearModelQuotaExceeded(clientID, modelID string)
14 ClientSupportsModel(clientID, modelID string) bool14 ClientSupportsModel(clientID, modelID string) bool
15 GetAvailableModels(handlerType string) []map[string]any15 GetAvailableModels(handlerType string) []map[string]any
16 GetAvailableModelsByProvider(provider string) []*ModelInfo
16}17}
17 18
18// GlobalModelRegistry returns the shared registry instance.19// GlobalModelRegistry returns the shared registry instance.
test/model_alias_thinking_suffix_test.go+211−0
@@ -0,0 +1,211 @@
1package test
2
3import (
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.
13func 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}