Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions cmd/moonbridge/minimax_registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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
}{
{"MiniMax-M3", 1000000, []string{"text", "image", "video"}},
{"MiniMax-M2.7", 204800, []string{"text"}},
}

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")
}
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)
}
}
}
82 changes: 82 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions internal/protocol/anthropic/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions metadata/models/MiniMax-M2.7.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"context_window": 204800,
"display_name": "MiniMax M2.7",
"description": "Text model with always-on reasoning.",
"supports_reasoning": true,
"input_modalities": ["text"]
}
7 changes: 7 additions & 0 deletions metadata/models/MiniMax-M3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"context_window": 1000000,
"display_name": "MiniMax M3",
"description": "Long-context multimodal model with adaptive reasoning.",
"supports_reasoning": true,
"input_modalities": ["text", "image", "video"]
}