Unified · Split
| @@ -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"}, |
| @@ -625,6 +625,131 @@ func (r *ModelRegistry) GetAvailableModels(handlerType string) []map[string]any · 624 unmodified lines | |||
| 625 | return models | 625 | 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 | ||
| 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 | |||
| 628 | // GetModelCount returns the number of available clients for a specific model | 753 | // GetModelCount returns the number of available clients for a specific model |
| 629 | // Parameters: | 754 | // Parameters: |
| 630 | // - modelID: The model ID to check | 755 | // - modelID: The model ID to check |
| @@ -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") |
| @@ -78,9 +78,9 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 77 unmodified lines | |||
| 78 | from := opts.SourceFormat | 78 | 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.SourceFormat | 217 | 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") |
| @@ -441,21 +441,18 @@ func ensureToolsArray(body []byte) []byte { · 440 unmodified lines | |||
| 441 | return updated | 441 | return updated |
| 442 | } | 442 | } |
| 443 | 443 | ||
| 444 | // preserveReasoningContentInMessages ensures reasoning_content from assistant messages in the | 444 | // 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 previous | 446 | // 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 be | 449 | // 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. |
| 451 | func preserveReasoningContentInMessages(body []byte) []byte { | 451 | func 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 preservation | 454 | // 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 body | 458 | 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 separation | 494 | // - MiniMax M2/M2.1: Uses reasoning_split=true for OpenAI-style reasoning separation |
| 498 | // - Other iFlow models: Uses chat_template_kwargs.enable_thinking (boolean) | ||
| 499 | func applyIFlowThinkingConfig(body []byte) []byte { | 495 | func 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 format | 505 | // 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: false | 509 | // 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 body | 515 | return body |
| 522 | } | 516 | } |
| 523 | 517 | ||
| 524 | // MiniMax-M2.1: Use reasoning_split=true for interleaved thinking | 518 | // 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 body | 521 | 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 body | 524 | return body |
| 538 | } | 525 | } |
| @@ -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. |
| 16 | func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte { | 16 | 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) | ||
| 18 | if !ok || (budgetOverride == nil && includeOverride == nil) { | 29 | if !ok || (budgetOverride == nil && includeOverride == nil) { |
| 19 | return payload | 30 | return payload |
| 20 | } | 31 | } |
| 21 | if !util.ModelSupportsThinking(model) { | 32 | if !util.ModelSupportsThinking(thinkingModel) { |
| 22 | return payload | 33 | 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 = &norm | 37 | 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. |
| 33 | func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte { | 44 | func 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 payload | 58 | return payload |
| 37 | } | 59 | } |
| 38 | if !util.ModelSupportsThinking(model) { | 60 | if !util.ModelSupportsThinking(thinkingModel) { |
| 39 | return payload | 61 | 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 = &norm | 65 | budgetOverride = &norm |
| 44 | } | 66 | } |
| 45 | return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride) | 67 | return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride) |
| @@ -118,76 +118,125 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream · 117 unmodified lines | |||
| 118 | // Handle content | 118 | // Handle content |
| 119 | if contentResult.Exists() && contentResult.IsArray() { | 119 | if contentResult.Exists() && contentResult.IsArray() { |
| 120 | var contentItems []string | 120 | 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 format | 149 | // 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 string | 154 | |
| 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 order | 166 | // 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 true | 172 | return true |
| 155 | }) | 173 | }) |
| 156 | 174 | ||
| 157 | // Emit text/image content as one message | 175 | // 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 := false | 188 | // 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 = false | 191 | } |
| 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()) > 0 | 195 | 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 message | 230 | 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 "", false | 356 | return "", false |
| 308 | } | 357 | } |
| 309 | } | 358 | } |
| 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 | } | ||
| @@ -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 | } | ||
| @@ -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 | } |
| @@ -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") |
| @@ -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 | }` |
| @@ -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. | ||
| 292 | func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any, body []byte) []byte { | 293 | 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) { | ||
| 294 | return body | 297 | 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 body | 309 | if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid { |
| 310 | return ApplyGeminiThinkingLevel(body, level, nil) | ||
| 311 | } | ||
| 299 | } | 312 | } |
| 300 | // Validate and apply the thinkingLevel | 313 | |
| 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 body | 322 | 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. | ||
| 310 | func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string]any, body []byte) []byte { | 329 | 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) { | ||
| 312 | return body | 333 | 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 body | 345 | if level, valid := ValidateGemini3ThinkingLevel(checkModel, effort); valid { |
| 346 | return ApplyGeminiCLIThinkingLevel(body, level, nil) | ||
| 347 | } | ||
| 317 | } | 348 | } |
| 318 | // Validate and apply the thinkingLevel | 349 | |
| 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 body | 358 | 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. |
| 329 | func ApplyDefaultThinkingIfNeededCLI(model string, body []byte) []byte { | 365 | func 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 body | 369 | return body |
| 332 | } | 370 | } |
| 333 | if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() { | 371 | if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() { |
| 334 | return body | 372 | return body |
| 335 | } | 373 | } |
| 336 | // Gemini 3 models use thinkingLevel instead of thinkingBudget | 374 | // 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 includeThoughts | 377 | // Only set includeThoughts |
| 340 | updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true) | 378 | updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true) |
| @@ -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 | } | ||
| @@ -12,9 +12,18 @@ func ModelSupportsThinking(model string) bool { · 11 unmodified lines | |||
| 12 | if model == "" { | 12 | if model == "" { |
| 13 | return false | 13 | 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 != nil | 17 | 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 false | 27 | 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, false | 73 | 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, false | 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 | ||
| 69 | } | 86 | } |
| 70 | return true, info.Thinking.Min, info.Thinking.Max, info.Thinking.ZeroAllowed, info.Thinking.DynamicAllowed | 87 | 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. |
| @@ -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 | ||
| 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 | |||
| 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. |
| 19 | func SetLogLevel(cfg *config.Config) { | 59 | func SetLogLevel(cfg *config.Config) { |
| @@ -81,7 +81,9 @@ func (m *Manager) applyOAuthModelMapping(auth *Auth, requestedModel string, meta · 80 unmodified lines | |||
| 81 | out[k] = v | 81 | out[k] = v |
| 82 | } | 82 | } |
| 83 | } | 83 | } |
| 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 | ||
| 85 | return upstreamModel, out | 87 | return upstreamModel, out |
| 86 | } | 88 | } |
| 87 | 89 | ||
| @@ -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) bool | 14 | ClientSupportsModel(clientID, modelID string) bool |
| 15 | GetAvailableModels(handlerType string) []map[string]any | 15 | 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. |
| @@ -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 | } | ||