diff --git a/cmd/moonbridge/minimax_registry_test.go b/cmd/moonbridge/minimax_registry_test.go new file mode 100644 index 00000000..fd7aeb45 --- /dev/null +++ b/cmd/moonbridge/minimax_registry_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "gopkg.in/yaml.v3" + "moonbridge/internal/config" +) + +func TestExampleConfigIncludesMiniMaxRegistry(t *testing.T) { + repoRoot := filepath.Join("..", "..") + example, err := os.ReadFile(filepath.Join(repoRoot, "config.example.yml")) + if err != nil { + t.Fatalf("read example config: %v", err) + } + var fileConfig config.FileConfig + if err := yaml.Unmarshal(example, &fileConfig); err != nil { + t.Fatalf("decode example config: %v", err) + } + for _, modelID := range []string{"MiniMax-M3", "MiniMax-M2.7"} { + if _, ok := fileConfig.Models[modelID]; !ok { + t.Fatalf("model %q is missing", modelID) + } + } + + providerTests := []struct { + key string + protocol string + baseURL string + }{ + {"minimax-global-anthropic", config.ProtocolAnthropic, "https://api.minimax.io/anthropic"}, + {"minimax-global-openai", config.ProtocolOpenAIChat, "https://api.minimax.io/v1"}, + {"minimax-cn-anthropic", config.ProtocolAnthropic, "https://api.minimaxi.com/anthropic"}, + {"minimax-cn-openai", config.ProtocolOpenAIChat, "https://api.minimaxi.com/v1"}, + } + for _, tt := range providerTests { + t.Run(tt.key, func(t *testing.T) { + provider, ok := fileConfig.Providers[tt.key] + if !ok { + t.Fatalf("provider %q is missing", tt.key) + } + if provider.Protocol != tt.protocol { + t.Fatalf("protocol = %q, want %q", provider.Protocol, tt.protocol) + } + if provider.BaseURL != tt.baseURL { + t.Fatalf("base URL = %q, want %q", provider.BaseURL, tt.baseURL) + } + assertMiniMaxOffers(t, provider.Offers) + }) + } + + for _, docsRoot := range []string{ + "https://platform.minimax.io/docs", + "https://platform.minimaxi.com/docs", + } { + if !strings.Contains(string(example), docsRoot) { + t.Fatalf("example config is missing documentation root %q", docsRoot) + } + } +} + +func TestMiniMaxModelMetadata(t *testing.T) { + tests := []struct { + modelID string + contextWindow int + modalities []string + reasoning []string + }{ + {"MiniMax-M3", 1000000, []string{"text", "image", "video"}, []string{"adaptive", "disabled"}}, + {"MiniMax-M2.7", 204800, []string{"text"}, []string{"always_on"}}, + } + + for _, tt := range tests { + t.Run(tt.modelID, func(t *testing.T) { + path := filepath.Join("..", "..", "metadata", "models", tt.modelID+".json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read model metadata: %v", err) + } + var metadata config.ModelDefFileConfig + if err := json.Unmarshal(raw, &metadata); err != nil { + t.Fatalf("decode model metadata: %v", err) + } + if metadata.ContextWindow != tt.contextWindow { + t.Fatalf("context window = %d, want %d", metadata.ContextWindow, tt.contextWindow) + } + if metadata.SupportsReasoning == nil || !*metadata.SupportsReasoning { + t.Fatal("supports_reasoning = false, want true") + } + gotReasoning := make([]string, 0, len(metadata.SupportedReasoningLevels)) + for _, level := range metadata.SupportedReasoningLevels { + gotReasoning = append(gotReasoning, level.Effort) + } + if !slices.Equal(gotReasoning, tt.reasoning) { + t.Fatalf("supported reasoning levels = %v, want %v", gotReasoning, tt.reasoning) + } + if !slices.Equal(metadata.InputModalities, tt.modalities) { + t.Fatalf("input modalities = %v, want %v", metadata.InputModalities, tt.modalities) + } + }) + } +} + +func assertMiniMaxOffers(t *testing.T, offers []config.OfferFileConfig) { + t.Helper() + want := map[string]config.ModelPricingFileConfig{ + "MiniMax-M3": { + InputPrice: 0.6, + OutputPrice: 2.4, + CacheReadPrice: 0.12, + }, + "MiniMax-M2.7": { + InputPrice: 0.3, + OutputPrice: 1.2, + CacheWritePrice: 0.375, + CacheReadPrice: 0.06, + }, + } + if len(offers) != len(want) { + t.Fatalf("offer count = %d, want %d", len(offers), len(want)) + } + for _, offer := range offers { + pricing, ok := want[offer.Model] + if !ok { + t.Fatalf("unexpected model offer %q", offer.Model) + } + if offer.Pricing != pricing { + t.Fatalf("pricing for %q = %+v, want %+v", offer.Model, offer.Pricing, pricing) + } + } +} diff --git a/config.example.yml b/config.example.yml index 118ccacd..6bee0ee2 100644 --- a/config.example.yml +++ b/config.example.yml @@ -158,6 +158,12 @@ models: claude-sonnet-4-20250514: extensions: {} # no overrides, metadata only + MiniMax-M3: + extensions: {} # no overrides, metadata only + + MiniMax-M2.7: + extensions: {} # no overrides, metadata only + # kimi-for-coding: # context_window: 128000 # extensions: @@ -229,6 +235,82 @@ providers: cache_write_price: 3.75 cache_read_price: 0.30 + # MiniMax global endpoints: https://platform.minimax.io/docs + minimax-global-anthropic: + protocol: "anthropic" + base_url: "https://api.minimax.io/anthropic" + api_key: "replace-me" + version: "2023-06-01" + user_agent: "moonbridge/1.0" + offers: + - model: MiniMax-M3 + pricing: + input_price: 0.6 + output_price: 2.4 + cache_read_price: 0.12 + - model: MiniMax-M2.7 + pricing: + input_price: 0.3 + output_price: 1.2 + cache_write_price: 0.375 + cache_read_price: 0.06 + + minimax-global-openai: + protocol: "openai-chat" + base_url: "https://api.minimax.io/v1" + api_key: "replace-me" + user_agent: "moonbridge/1.0" + offers: + - model: MiniMax-M3 + pricing: + input_price: 0.6 + output_price: 2.4 + cache_read_price: 0.12 + - model: MiniMax-M2.7 + pricing: + input_price: 0.3 + output_price: 1.2 + cache_write_price: 0.375 + cache_read_price: 0.06 + + # MiniMax China endpoints: https://platform.minimaxi.com/docs + minimax-cn-anthropic: + protocol: "anthropic" + base_url: "https://api.minimaxi.com/anthropic" + api_key: "replace-me" + version: "2023-06-01" + user_agent: "moonbridge/1.0" + offers: + - model: MiniMax-M3 + pricing: + input_price: 0.6 + output_price: 2.4 + cache_read_price: 0.12 + - model: MiniMax-M2.7 + pricing: + input_price: 0.3 + output_price: 1.2 + cache_write_price: 0.375 + cache_read_price: 0.06 + + minimax-cn-openai: + protocol: "openai-chat" + base_url: "https://api.minimaxi.com/v1" + api_key: "replace-me" + user_agent: "moonbridge/1.0" + offers: + - model: MiniMax-M3 + pricing: + input_price: 0.6 + output_price: 2.4 + cache_read_price: 0.12 + - model: MiniMax-M2.7 + pricing: + input_price: 0.3 + output_price: 1.2 + cache_write_price: 0.375 + cache_read_price: 0.06 + # Google Gemini (Vertex AI via GenAI SDK) # gemini: # protocol: "google-genai" diff --git a/internal/protocol/anthropic/client_test.go b/internal/protocol/anthropic/client_test.go index 70c3fb4d..ae47ad8d 100644 --- a/internal/protocol/anthropic/client_test.go +++ b/internal/protocol/anthropic/client_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "net/http" + "net/http/httptest" "strings" "testing" @@ -77,6 +78,39 @@ func TestClientCreateMessageSendsHeadersAndParsesResponse(t *testing.T) { } } +func TestClientCreateMessagePreservesBaseURLPathPrefix(t *testing.T) { + var requestPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(anthropic.MessageResponse{ + ID: "msg_123", + Type: "message", + Role: "assistant", + Content: []anthropic.ContentBlock{{Type: "text", Text: "Hello"}}, + }) + })) + defer server.Close() + + client := anthropic.NewClient(anthropic.ClientConfig{ + BaseURL: server.URL + "/anthropic", + APIKey: "upstream-key", + Version: "2023-06-01", + Client: server.Client(), + }) + _, err := client.CreateMessage(context.Background(), anthropic.MessageRequest{ + Model: "test-model", + MaxTokens: 64, + Messages: []anthropic.Message{{Role: "user", Content: []anthropic.ContentBlock{{Type: "text", Text: "Hi"}}}}, + }) + if err != nil { + t.Fatalf("CreateMessage() error = %v", err) + } + if requestPath != "/anthropic/v1/messages" { + t.Fatalf("request path = %q, want %q", requestPath, "/anthropic/v1/messages") + } +} + func TestClientStreamMessageParsesSSEEvents(t *testing.T) { body := strings.Join([]string{ "event: message_start", diff --git a/metadata/models/MiniMax-M2.7.json b/metadata/models/MiniMax-M2.7.json new file mode 100644 index 00000000..361e239f --- /dev/null +++ b/metadata/models/MiniMax-M2.7.json @@ -0,0 +1,10 @@ +{ + "context_window": 204800, + "display_name": "MiniMax M2.7", + "description": "Text model with always-on reasoning.", + "supports_reasoning": true, + "supported_reasoning_levels": [ + {"effort": "always_on", "description": "Always-on reasoning"} + ], + "input_modalities": ["text"] +} diff --git a/metadata/models/MiniMax-M3.json b/metadata/models/MiniMax-M3.json new file mode 100644 index 00000000..6a6804a4 --- /dev/null +++ b/metadata/models/MiniMax-M3.json @@ -0,0 +1,11 @@ +{ + "context_window": 1000000, + "display_name": "MiniMax M3", + "description": "Long-context multimodal model with adaptive reasoning.", + "supports_reasoning": true, + "supported_reasoning_levels": [ + {"effort": "adaptive", "description": "Adaptive reasoning"}, + {"effort": "disabled", "description": "Reasoning disabled"} + ], + "input_modalities": ["text", "image", "video"] +}