diff --git a/apps/picooraclaw/.env.example b/apps/picooraclaw/.env.example index 7e6ed72b..edefd622 100644 --- a/apps/picooraclaw/.env.example +++ b/apps/picooraclaw/.env.example @@ -5,6 +5,7 @@ # ANTHROPIC_API_KEY=sk-ant-xxx # OPENAI_API_KEY=sk-xxx # GEMINI_API_KEY=xxx +# MINIMAX_API_KEY=xxx # ── Chat Channel ────────────────────────── # TELEGRAM_BOT_TOKEN=123456:ABC... diff --git a/apps/picooraclaw/README.md b/apps/picooraclaw/README.md index b4337dd1..d2699afc 100644 --- a/apps/picooraclaw/README.md +++ b/apps/picooraclaw/README.md @@ -691,6 +691,32 @@ Get a key at [openrouter.ai/keys](https://openrouter.ai/keys) (200K free tokens/ +
+MiniMax (high-performance, great value) + +```json +{ + "agents": { + "defaults": { + "provider": "minimax", + "model": "MiniMax-M2.7" + } + }, + "providers": { + "minimax": { + "api_key": "your-key", + "api_base": "https://api.minimax.io/v1" + } + } +} +``` + +Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed` (faster). + +Get a key at [platform.minimax.io](https://platform.minimax.io). + +
+
Zhipu (best for Chinese users) @@ -728,6 +754,7 @@ Get a key at [bigmodel.cn](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys | `gemini` | Gemini models | [aistudio.google.com](https://aistudio.google.com) | | `groq` | Fast inference + voice transcription | [console.groq.com](https://console.groq.com) | | `deepseek` | DeepSeek models | [platform.deepseek.com](https://platform.deepseek.com) | +| `minimax` | MiniMax models (M2.7) | [platform.minimax.io](https://platform.minimax.io) | | `zhipu` | Zhipu/GLM models | [bigmodel.cn](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
@@ -1001,7 +1028,7 @@ docker compose run --rm picoclaw-agent -m "What is 2+2?" ## Features - Single static binary (~10MB RAM), runs on RISC-V/ARM64/x86_64 -- Ollama, OpenRouter, Anthropic, OpenAI, Gemini, Zhipu, DeepSeek, Groq providers +- Ollama, OpenRouter, Anthropic, OpenAI, Gemini, Zhipu, DeepSeek, Groq, MiniMax providers - **Default: [Oracle AI Database 26ai Free](https://www.oracle.com/database/free/)** with AI Vector Search (384-dim ONNX embeddings) - Chat channels: Telegram, Discord, Slack, QQ, DingTalk, LINE, Feishu, WhatsApp - Scheduled tasks via cron expressions diff --git a/apps/picooraclaw/config/config.example.json b/apps/picooraclaw/config/config.example.json index 01a2b37e..483845aa 100644 --- a/apps/picooraclaw/config/config.example.json +++ b/apps/picooraclaw/config/config.example.json @@ -108,6 +108,10 @@ "ollama": { "api_key": "", "api_base": "http://localhost:11434/v1" + }, + "minimax": { + "api_key": "", + "api_base": "https://api.minimax.io/v1" } }, "tools": { diff --git a/apps/picooraclaw/pkg/config/config.go b/apps/picooraclaw/pkg/config/config.go index 66629357..145cb116 100644 --- a/apps/picooraclaw/pkg/config/config.go +++ b/apps/picooraclaw/pkg/config/config.go @@ -228,6 +228,7 @@ type ProvidersConfig struct { Moonshot ProviderConfig `json:"moonshot"` DeepSeek ProviderConfig `json:"deepseek"` GitHubCopilot ProviderConfig `json:"github_copilot"` + MiniMax ProviderConfig `json:"minimax"` } type ProviderConfig struct { @@ -369,6 +370,7 @@ func DefaultConfig() *Config { Gemini: ProviderConfig{}, Nvidia: ProviderConfig{}, Moonshot: ProviderConfig{}, + MiniMax: ProviderConfig{}, }, Gateway: GatewayConfig{ Host: "0.0.0.0", diff --git a/apps/picooraclaw/pkg/providers/http_provider.go b/apps/picooraclaw/pkg/providers/http_provider.go index 23888aea..13ce031f 100644 --- a/apps/picooraclaw/pkg/providers/http_provider.go +++ b/apps/picooraclaw/pkg/providers/http_provider.go @@ -57,7 +57,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too // Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5, groq/openai/gpt-oss-120b -> openai/gpt-oss-120b, ollama/qwen2.5:14b -> qwen2.5:14b) if idx := strings.Index(model, "/"); idx != -1 { prefix := model[:idx] - if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" { + if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" || prefix == "minimax" { model = model[idx+1:] } } @@ -86,6 +86,15 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too // Kimi k2 models only support temperature=1 if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { requestBody["temperature"] = 1.0 + } else if strings.Contains(lowerModel, "MiniMax") || strings.Contains(lowerModel, "minimax") { + // MiniMax models require temperature in (0.0, 1.0]; 0 is not allowed + if temperature <= 0 { + requestBody["temperature"] = 1.0 + } else if temperature > 1.0 { + requestBody["temperature"] = 1.0 + } else { + requestBody["temperature"] = temperature + } } else { requestBody["temperature"] = temperature } @@ -467,6 +476,15 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { model = "deepseek-chat" } } + case "minimax", "MiniMax": + if cfg.Providers.MiniMax.APIKey != "" { + apiKey = cfg.Providers.MiniMax.APIKey + apiBase = cfg.Providers.MiniMax.APIBase + if apiBase == "" { + apiBase = "https://api.minimax.io/v1" + } + proxy = cfg.Providers.MiniMax.Proxy + } case "github_copilot", "copilot": if cfg.Providers.GitHubCopilot.APIBase != "" { apiBase = cfg.Providers.GitHubCopilot.APIBase @@ -544,6 +562,13 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { if apiBase == "" { apiBase = "https://integrate.api.nvidia.com/v1" } + case (strings.Contains(lowerModel, "minimax") || strings.Contains(lowerModel, "MiniMax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.MiniMax.APIKey != "": + apiKey = cfg.Providers.MiniMax.APIKey + apiBase = cfg.Providers.MiniMax.APIBase + proxy = cfg.Providers.MiniMax.Proxy + if apiBase == "" { + apiBase = "https://api.minimax.io/v1" + } case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIBase != "": apiKey = cfg.Providers.Ollama.APIKey if apiKey == "" { diff --git a/apps/picooraclaw/pkg/providers/minimax_provider_test.go b/apps/picooraclaw/pkg/providers/minimax_provider_test.go new file mode 100644 index 00000000..7e0c573b --- /dev/null +++ b/apps/picooraclaw/pkg/providers/minimax_provider_test.go @@ -0,0 +1,363 @@ +package providers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/jasperan/picooraclaw/pkg/config" +) + +func TestMiniMaxProvider_ChatRoundTrip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer test-minimax-key" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + + // Verify model is passed correctly + if reqBody["model"] != "MiniMax-M2.7" { + http.Error(w, "unexpected model", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "id": "chatcmpl-test", + "object": "chat.completion", + "model": reqBody["model"], + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]interface{}{ + "role": "assistant", + "content": "Hello from MiniMax!", + }, + "finish_reason": "stop", + }, + }, + "usage": map[string]interface{}{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider := NewHTTPProvider("test-minimax-key", server.URL, "") + + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(context.Background(), messages, nil, "MiniMax-M2.7", map[string]interface{}{ + "max_tokens": 1024, + }) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hello from MiniMax!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello from MiniMax!") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage.PromptTokens != 10 { + t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens) + } + if resp.Usage.CompletionTokens != 5 { + t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens) + } +} + +func TestMiniMaxProvider_TemperatureClamp(t *testing.T) { + tests := []struct { + name string + temperature float64 + wantTemp float64 + }{ + {"zero clamped to 1.0", 0.0, 1.0}, + {"negative clamped to 1.0", -0.5, 1.0}, + {"above 1.0 clamped to 1.0", 1.5, 1.0}, + {"valid temperature passed through", 0.7, 0.7}, + {"max valid temperature", 1.0, 1.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedTemp float64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + if temp, ok := reqBody["temperature"].(float64); ok { + capturedTemp = temp + } + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + {"message": map[string]interface{}{"content": "ok"}, "finish_reason": "stop"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider := NewHTTPProvider("test-key", server.URL, "") + messages := []Message{{Role: "user", Content: "test"}} + _, err := provider.Chat(context.Background(), messages, nil, "MiniMax-M2.7", map[string]interface{}{ + "temperature": tt.temperature, + }) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if capturedTemp != tt.wantTemp { + t.Errorf("temperature = %f, want %f", capturedTemp, tt.wantTemp) + } + }) + } +} + +func TestMiniMaxProvider_ModelPrefixStripping(t *testing.T) { + var capturedModel string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + capturedModel = reqBody["model"].(string) + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + {"message": map[string]interface{}{"content": "ok"}, "finish_reason": "stop"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider := NewHTTPProvider("test-key", server.URL, "") + messages := []Message{{Role: "user", Content: "test"}} + + _, err := provider.Chat(context.Background(), messages, nil, "minimax/MiniMax-M2.7", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if capturedModel != "MiniMax-M2.7" { + t.Errorf("model = %q, want %q (prefix should be stripped)", capturedModel, "MiniMax-M2.7") + } +} + +func TestMiniMaxProvider_ToolCalling(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + + // Verify tools are included in the request + if _, ok := reqBody["tools"]; !ok { + http.Error(w, "tools not found in request", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{ + "content": "", + "tool_calls": []map[string]interface{}{ + { + "id": "call_123", + "type": "function", + "function": map[string]interface{}{ + "name": "get_weather", + "arguments": `{"city":"Tokyo"}`, + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider := NewHTTPProvider("test-key", server.URL, "") + messages := []Message{{Role: "user", Content: "What's the weather in Tokyo?"}} + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get weather for a city", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"city": map[string]interface{}{"type": "string"}}, + "required": []interface{}{"city"}, + }, + }, + }, + } + + resp, err := provider.Chat(context.Background(), messages, tools, "MiniMax-M2.7", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].Name != "get_weather" { + t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather") + } + if resp.ToolCalls[0].Arguments["city"] != "Tokyo" { + t.Errorf("ToolCalls[0].Arguments[city] = %q, want %q", resp.ToolCalls[0].Arguments["city"], "Tokyo") + } +} + +func TestMiniMaxProvider_DefaultBaseURL(t *testing.T) { + provider := NewHTTPProvider("test-key", "https://api.minimax.io/v1", "") + if provider.apiBase != "https://api.minimax.io/v1" { + t.Errorf("apiBase = %q, want %q", provider.apiBase, "https://api.minimax.io/v1") + } +} + +func TestMiniMaxProvider_Streaming(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + + if reqBody["stream"] != true { + http.Error(w, "stream not enabled", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + chunks := []string{ + `data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}`, + `data: {"choices":[{"delta":{"content":" from"},"index":0}]}`, + `data: {"choices":[{"delta":{"content":" MiniMax!"},"index":0,"finish_reason":"stop"}]}`, + `data: [DONE]`, + } + for _, chunk := range chunks { + w.Write([]byte(chunk + "\n\n")) + flusher.Flush() + } + })) + defer server.Close() + + provider := NewHTTPProvider("test-key", server.URL, "") + messages := []Message{{Role: "user", Content: "test"}} + + var accumulated string + callback := StreamCallback(func(chunk StreamChunk) { + accumulated += chunk.Content + }) + + resp, err := provider.Chat(context.Background(), messages, nil, "MiniMax-M2.7", map[string]interface{}{ + "stream_callback": callback, + }) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hello from MiniMax!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello from MiniMax!") + } + if accumulated != "Hello from MiniMax!" { + t.Errorf("accumulated = %q, want %q", accumulated, "Hello from MiniMax!") + } +} + +func TestCreateProvider_MiniMax(t *testing.T) { + // Test that CreateProvider correctly routes to MiniMax via config + // This is a compile-time check that the config struct has the MiniMax field + // and that CreateProvider handles the "minimax" provider name + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "minimax", + Model: "MiniMax-M2.7", + }, + }, + Providers: config.ProvidersConfig{ + MiniMax: config.ProviderConfig{ + APIKey: "test-minimax-key", + APIBase: "https://api.minimax.io/v1", + }, + }, + } + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error: %v", err) + } + httpProvider, ok := provider.(*HTTPProvider) + if !ok { + t.Fatalf("provider type = %T, want *HTTPProvider", provider) + } + if httpProvider.apiKey != "test-minimax-key" { + t.Errorf("apiKey = %q, want %q", httpProvider.apiKey, "test-minimax-key") + } + if httpProvider.apiBase != "https://api.minimax.io/v1" { + t.Errorf("apiBase = %q, want %q", httpProvider.apiBase, "https://api.minimax.io/v1") + } +} + +func TestCreateProvider_MiniMaxDefaultBaseURL(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "minimax", + Model: "MiniMax-M2.7", + }, + }, + Providers: config.ProvidersConfig{ + MiniMax: config.ProviderConfig{ + APIKey: "test-minimax-key", + }, + }, + } + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error: %v", err) + } + httpProvider := provider.(*HTTPProvider) + if httpProvider.apiBase != "https://api.minimax.io/v1" { + t.Errorf("apiBase = %q, want default %q", httpProvider.apiBase, "https://api.minimax.io/v1") + } +} + +func TestCreateProvider_MiniMaxAlias(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Provider: "MiniMax", + Model: "MiniMax-M2.7", + }, + }, + Providers: config.ProvidersConfig{ + MiniMax: config.ProviderConfig{ + APIKey: "test-minimax-key", + }, + }, + } + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() with alias error: %v", err) + } + httpProvider := provider.(*HTTPProvider) + if httpProvider.apiKey != "test-minimax-key" { + t.Errorf("apiKey = %q, want %q", httpProvider.apiKey, "test-minimax-key") + } +}