From b3354a1bbc94bc43843c8a7083a2e494117962af Mon Sep 17 00:00:00 2001 From: newkirk Date: Fri, 29 May 2026 12:19:24 -0400 Subject: [PATCH] Add Xiaomi MiMo provider (mimo-v2.5) support --- .env.example | 3 + internal/config/config.go | 15 ++++ internal/models/registry.go | 1 + internal/providers/xiaomi.go | 133 +++++++++++++++++++++++++++++ internal/server/models_config.go | 1 + internal/server/providers_admin.go | 11 ++- internal/server/server.go | 17 +++- 7 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 internal/providers/xiaomi.go diff --git a/.env.example b/.env.example index c5d2f9c3..d6bcb77a 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ DEEPSEEK_API_KEY=sk-... MOONSHOT_API_KEY=sk-... GROK_API_KEY=xai-... +# Xiaomi MiMo +XIAOMI_API_KEY=sk-... + # ============================================================================== # Server Configuration # ============================================================================== diff --git a/internal/config/config.go b/internal/config/config.go index 931c02e2..53e5670b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,7 @@ type ProviderConfig struct { Moonshot MoonshotConfig `mapstructure:"moonshot"` Grok GrokConfig `mapstructure:"grok"` Ollama OllamaConfig `mapstructure:"ollama"` + Xiaomi XiaomiConfig `mapstructure:"xiaomi"` } type OpenAIConfig struct { @@ -81,6 +82,13 @@ type OllamaConfig struct { Models []string `mapstructure:"models"` } +type XiaomiConfig struct { + APIKeyEnv string `mapstructure:"api_key_env"` + BaseURL string `mapstructure:"base_url"` + DefaultModel string `mapstructure:"default_model"` + Enabled bool `mapstructure:"enabled"` +} + func Load() (*Config, error) { v := viper.New() @@ -120,6 +128,11 @@ func Load() (*Config, error) { v.SetDefault("providers.ollama.enabled", false) v.SetDefault("providers.ollama.models", []string{}) + v.SetDefault("providers.xiaomi.api_key_env", "XIAOMI_API_KEY") + v.SetDefault("providers.xiaomi.base_url", "https://api.xiaomimimo.com/v1") + v.SetDefault("providers.xiaomi.default_model", "mimo-v2.5") + v.SetDefault("providers.xiaomi.enabled", true) + // Environment variables v.SetEnvPrefix("LLM_PROXY") v.SetEnvKeyReplacer(strings.NewReplacer(".", "__")) @@ -210,6 +223,8 @@ func (c *Config) GetAPIKey(provider string) (string, error) { case "ollama": // Ollama doesn't require an API key return "", nil + case "xiaomi": + envVar = c.Providers.Xiaomi.APIKeyEnv default: return "", fmt.Errorf("unknown provider: %s", provider) } diff --git a/internal/models/registry.go b/internal/models/registry.go index a51ae851..8e6c5f3b 100644 --- a/internal/models/registry.go +++ b/internal/models/registry.go @@ -18,6 +18,7 @@ var CanonicalProviders = []string{ "mistral", "cohere", "minimax", + "xiaomi", } type ModelRegistry struct { diff --git a/internal/providers/xiaomi.go b/internal/providers/xiaomi.go new file mode 100644 index 00000000..10e39025 --- /dev/null +++ b/internal/providers/xiaomi.go @@ -0,0 +1,133 @@ +package providers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "gophergate/internal/config" + "gophergate/internal/models" +) + +type XiaomiProvider struct { + client *resty.Client + config config.XiaomiConfig + apiKey string +} + +func NewXiaomiProvider(cfg config.XiaomiConfig, apiKey string) *XiaomiProvider { + return &XiaomiProvider{ + client: resty.New().SetTimeout(10 * time.Minute), + config: cfg, + apiKey: strings.TrimSpace(apiKey), + } +} + +func (p *XiaomiProvider) Name() string { + return "xiaomi" +} + +func (p *XiaomiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) { + messagesJSON, err := MessagesToOpenAIJSON(req.Messages) + if err != nil { + return nil, fmt.Errorf("failed to convert messages: %w", err) + } + + body := BuildOpenAIBody(req, messagesJSON, false) + + baseURL := strings.TrimRight(p.config.BaseURL, "/") + + resp, err := p.client.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+p.apiKey). + SetHeader("Content-Type", "application/json"). + SetHeader("Accept", "application/json"). + SetBody(body). + Post(fmt.Sprintf("%s/chat/completions", baseURL)) + + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + if !resp.IsSuccess() { + msg := resp.String() + if msg == "" { + if b := resp.Body(); len(b) > 0 { + msg = string(b) + } + } + if msg == "" { + if body, err := io.ReadAll(resp.RawBody()); err == nil { + msg = string(body) + } + } + return nil, fmt.Errorf("Xiaomi API error (%d): %s", resp.StatusCode(), msg) + } + + var respJSON map[string]interface{} + if err := json.Unmarshal(resp.Body(), &respJSON); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return ParseOpenAIResponse(respJSON, req.Model) +} + +func (p *XiaomiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) { + messagesJSON, err := MessagesToOpenAIJSON(req.Messages) + if err != nil { + return nil, fmt.Errorf("failed to convert messages: %w", err) + } + + body := BuildOpenAIBody(req, messagesJSON, true) + + baseURL := strings.TrimRight(p.config.BaseURL, "/") + + resp, err := p.client.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+p.apiKey). + SetHeader("Content-Type", "application/json"). + SetHeader("Accept", "text/event-stream"). + SetBody(body). + SetDoNotParseResponse(true). + Post(fmt.Sprintf("%s/chat/completions", baseURL)) + + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + if !resp.IsSuccess() { + msg := resp.String() + if msg == "" { + if body, err := io.ReadAll(resp.RawBody()); err == nil { + msg = string(body) + } + } + return nil, fmt.Errorf("Xiaomi API error (%d): %s", resp.StatusCode(), msg) + } + + ch := make(chan *models.ChatCompletionStreamResponse) + go func() { + defer close(ch) + if err := StreamOpenAI(resp.RawBody(), ch); err != nil { + fmt.Printf("Xiaomi Stream error: %v\n", err) + } + }() + + return ch, nil +} + +func (p *XiaomiProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) { + return nil, fmt.Errorf("xiaomi does not support image generation") +} + +func (p *XiaomiProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) { + return nil, fmt.Errorf("responses API not supported by xiaomi") +} + +func (p *XiaomiProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) { + return nil, fmt.Errorf("responses API not supported by xiaomi") +} diff --git a/internal/server/models_config.go b/internal/server/models_config.go index 32d35d69..5deee261 100644 --- a/internal/server/models_config.go +++ b/internal/server/models_config.go @@ -19,6 +19,7 @@ func (s *Server) handleGetModels(c *gin.Context) { "deepseek": "deepseek", "xai": "grok", "ollama": "ollama", + "xiaomi": "xiaomi", } // Merge registry models with DB overrides diff --git a/internal/server/providers_admin.go b/internal/server/providers_admin.go index 58ebaad4..b8e020c9 100644 --- a/internal/server/providers_admin.go +++ b/internal/server/providers_admin.go @@ -25,7 +25,7 @@ func (s *Server) handleGetProviders(c *gin.Context) { dbMap[cfg.ID] = cfg } - providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama"} + providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama", "xiaomi"} var result []gin.H for _, id := range providerIDs { @@ -54,6 +54,10 @@ func (s *Server) handleGetProviders(c *gin.Context) { name = "xAI Grok" enabled = s.cfg.Providers.Grok.Enabled baseURL = s.cfg.Providers.Grok.BaseURL + case "xiaomi": + name = "Xiaomi MiMo" + enabled = s.cfg.Providers.Xiaomi.Enabled + baseURL = s.cfg.Providers.Xiaomi.BaseURL case "ollama": name = "Ollama" enabled = s.cfg.Providers.Ollama.Enabled @@ -109,6 +113,9 @@ func (s *Server) handleGetProviders(c *gin.Context) { if id == "grok" { registryID = "xai" } + if id == "xiaomi" { + registryID = "xiaomi" + } if pInfo, ok := s.registry.Providers[registryID]; ok { for mID := range pInfo.Models { @@ -226,6 +233,8 @@ func (s *Server) handleTestProvider(c *gin.Context) { testReq.Model = "kimi-k2.5" } else if name == "grok" { testReq.Model = "grok-4-1-fast-non-reasoning" + } else if name == "xiaomi" { + testReq.Model = "mimo-v2.5" } _, err := provider.ChatCompletion(c.Request.Context(), testReq) diff --git a/internal/server/server.go b/internal/server/server.go index 5e135835..989589f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -85,7 +85,7 @@ func (s *Server) RefreshProviders() error { dbMap[cfg.ID] = cfg } - providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama"} + providerIDs := []string{"openai", "gemini", "deepseek", "moonshot", "grok", "ollama", "xiaomi"} for _, id := range providerIDs { // Default values from config enabled := false @@ -113,6 +113,10 @@ func (s *Server) RefreshProviders() error { enabled = s.cfg.Providers.Grok.Enabled baseURL = s.cfg.Providers.Grok.BaseURL apiKey, _ = s.cfg.GetAPIKey("grok") + case "xiaomi": + enabled = s.cfg.Providers.Xiaomi.Enabled + baseURL = s.cfg.Providers.Xiaomi.BaseURL + apiKey, _ = s.cfg.GetAPIKey("xiaomi") } // Overrides from DB @@ -167,6 +171,10 @@ func (s *Server) RefreshProviders() error { cfg := s.cfg.Providers.Ollama cfg.BaseURL = baseURL p = providers.NewOllamaProvider(cfg) + case "xiaomi": + cfg := s.cfg.Providers.Xiaomi + cfg.BaseURL = baseURL + p = providers.NewXiaomiProvider(cfg, apiKey) } if p != nil { @@ -313,7 +321,7 @@ func (s *Server) handleResponses(c *gin.Context) { // (same pattern as handleChatCompletions). modelGroup := "" modelID := req.Model - prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/"} + prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/", "xiaomi/"} for _, p := range prefixes { if strings.HasPrefix(modelID, p) { modelID = strings.TrimPrefix(modelID, p) @@ -446,6 +454,7 @@ func (s *Server) handleListModels(c *gin.Context) { "xai": true, // Models from models.dev use 'xai' ID for Grok "llmgateway": true, // Catch-all for newer models "ollama": true, + "xiaomi": true, // Xiaomi MiMo models } s.registryMu.RLock() @@ -529,6 +538,8 @@ func (s *Server) selectProvider(modelID string) (providers.Provider, string, err strings.Contains(modelLower, "codellama") || strings.Contains(modelLower, "command-r") { providerName = "ollama" + } else if strings.HasPrefix(modelLower, "xiaomi/") || strings.Contains(modelLower, "mimo") || strings.Contains(modelLower, "xiaomi") { + providerName = "xiaomi" } p, ok := s.providers[providerName] @@ -548,7 +559,7 @@ func (s *Server) handleChatCompletions(c *gin.Context) { // Strip common prefixes and prepare model ID modelID := req.Model - prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/"} + prefixes := []string{"gemini/", "google/", "openai/", "deepseek/", "moonshot/", "grok/", "ollama/", "xiaomi/"} for _, p := range prefixes { if strings.HasPrefix(modelID, p) { modelID = strings.TrimPrefix(modelID, p)