From 8a4d505bbe11dbe70e13521e283f7b51db43ffd9 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:24:25 +0800 Subject: [PATCH] Add MiniMax provider support --- .env.example | 7 + cmd/grok2api/main.go | 27 +++- contracts/env-manifest.json | 35 ++++- internal/config/config.go | 32 +++- internal/config/config_test.go | 33 ++++ internal/models/catalog.go | 10 ++ internal/models/catalog_test.go | 21 +++ internal/proxy/chat.go | 14 +- internal/proxy/failover.go | 2 +- internal/server/native_messages.go | 192 +++++++++++++++++++++++ internal/server/native_messages_test.go | 57 +++++++ internal/server/server.go | 77 ++++++--- internal/server/upstream_status.go | 14 +- internal/upstream/grok/client.go | 9 ++ internal/upstream/minimax/client.go | 155 ++++++++++++++++++ internal/upstream/minimax/client_test.go | 144 +++++++++++++++++ internal/upstream/minimax/provider.go | 135 ++++++++++++++++ 17 files changed, 926 insertions(+), 38 deletions(-) create mode 100644 internal/server/native_messages.go create mode 100644 internal/server/native_messages_test.go create mode 100644 internal/upstream/minimax/client.go create mode 100644 internal/upstream/minimax/client_test.go create mode 100644 internal/upstream/minimax/provider.go diff --git a/.env.example b/.env.example index 0ee71c8c..8200708c 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,13 @@ GROK2API_ADMIN_PASSWORD=change-me GROK2API_DEFAULT_MODEL=grok-4.5 GROK2API_ACCOUNT_MODE=round_robin +# MiniMax provider (set the API key to enable it). +# MINIMAX_API_KEY= +# MINIMAX_REGION=global_en +# MINIMAX_OPENAI_BASE_URL=https://api.minimax.io/v1 +# MINIMAX_ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic +# GROK2API_DEFAULT_MODEL=MiniMax-M3 + # ── High concurrency (required) ─────────────────────── # auto = max(2, min(8, cpu_count)) when unset; compose default is 2 GROK2API_WORKERS=2 diff --git a/cmd/grok2api/main.go b/cmd/grok2api/main.go index 5f30acd2..08e27151 100644 --- a/cmd/grok2api/main.go +++ b/cmd/grok2api/main.go @@ -17,6 +17,7 @@ import ( "github.com/hm2899/grokcli-2api/internal/maintainer" "github.com/hm2899/grokcli-2api/internal/modelhealth" "github.com/hm2899/grokcli-2api/internal/models" + "github.com/hm2899/grokcli-2api/internal/pool" "github.com/hm2899/grokcli-2api/internal/protocol/historycompact" "github.com/hm2899/grokcli-2api/internal/protocol/toolcall" "github.com/hm2899/grokcli-2api/internal/quota" @@ -25,6 +26,7 @@ import ( "github.com/hm2899/grokcli-2api/internal/store/postgres" "github.com/hm2899/grokcli-2api/internal/store/redis" "github.com/hm2899/grokcli-2api/internal/upstream/grok" + "github.com/hm2899/grokcli-2api/internal/upstream/minimax" "github.com/hm2899/grokcli-2api/internal/upstream/oidc" ) @@ -79,7 +81,7 @@ func main() { } } - if store != nil { + if store != nil && !cfg.MiniMaxEnabled() { oidcClient := &oidc.Client{} maintSvc = maintainer.New(store, redisClient, oidcClient) healthSvc = modelhealth.New(store, redisClient, cfg.UpstreamBase, []string{cfg.DefaultModel}) @@ -222,6 +224,24 @@ func main() { slog.Warn("failed to load durable settings at boot", "error", err) } } + var upstreamHTTP grok.Opener = &grok.Client{BaseURL: cfg.UpstreamBase} + var candidates []pool.Candidate + var quotaSvc *quota.Service + if cfg.MiniMaxEnabled() { + upstreamHTTP = &minimax.Client{ + OpenAIBaseURL: cfg.MiniMaxOpenAIBaseURL, + AnthropicBaseURL: cfg.MiniMaxAnthropicBaseURL, + } + candidates = []pool.Candidate{{ + ID: "minimax", + Token: cfg.MiniMaxAPIKey, + Enabled: true, + Weight: 1, + }} + } else { + quotaSvc = quota.New(store, cfg.UpstreamBase) + } + handler := server.NewMux(server.Options{ Ready: readiness.Ready, Reason: readiness.Reason, @@ -235,15 +255,16 @@ func main() { APIKeys: auth.NewAPIKeyVerifier(cfg, store), Models: models.NewCatalog(cfg, store), Store: store, + Candidates: candidates, AdminSessions: adminSessions, PickObserver: redis.NewPickObserver(redisClient), AffinityStore: redis.NewChatAffinity(redisClient, 24*time.Hour), - Upstream: &grok.Client{BaseURL: cfg.UpstreamBase}, + Upstream: upstreamHTTP, Redis: redisClient, Leader: leader, Maintainer: maintSvc, ModelHealth: healthSvc, - Quota: quota.New(store, cfg.UpstreamBase), + Quota: quotaSvc, Config: cfg, RuntimeConfig: &runtimeCfg, RegistrationURL: cfg.RegistrationServiceURL, diff --git a/contracts/env-manifest.json b/contracts/env-manifest.json index f1202923..724481cb 100644 --- a/contracts/env-manifest.json +++ b/contracts/env-manifest.json @@ -20,7 +20,40 @@ "type": "string", "default": "grok-4.5", "scope": "startup_snapshot", - "future_scope": "runtime_setting" + "future_scope": "runtime_setting", + "go_behavior": "Defaults to MiniMax-M3 when MINIMAX_API_KEY is configured." + }, + { + "name": "MINIMAX_API_KEY", + "type": "string", + "default": "", + "scope": "startup", + "sensitive": true, + "go_behavior": "Enables the MiniMax upstream adapter." + }, + { + "name": "MINIMAX_REGION", + "type": "enum", + "values": [ + "global_en", + "cn_zh" + ], + "default": "global_en", + "scope": "startup" + }, + { + "name": "MINIMAX_OPENAI_BASE_URL", + "type": "url", + "default": "https://api.minimax.io/v1", + "scope": "startup", + "go_behavior": "Defaults to the OpenAI-compatible endpoint selected by MINIMAX_REGION." + }, + { + "name": "MINIMAX_ANTHROPIC_BASE_URL", + "type": "url", + "default": "https://api.minimax.io/anthropic", + "scope": "startup", + "go_behavior": "Defaults to the Anthropic-compatible endpoint selected by MINIMAX_REGION." }, { "name": "GROK_CLI_CHAT_PROXY_BASE_URL", diff --git a/internal/config/config.go b/internal/config/config.go index 8f50533c..dc74fdad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" "time" + + "github.com/hm2899/grokcli-2api/internal/upstream/minimax" ) const ( @@ -29,6 +31,10 @@ type Config struct { Port int DefaultModel string UpstreamBase string + MiniMaxAPIKey string + MiniMaxRegion string + MiniMaxOpenAIBaseURL string + MiniMaxAnthropicBaseURL string RedisURL string DatabaseURL string RedisPrefix string @@ -153,6 +159,22 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + miniMaxRegion, err := envEnum("MINIMAX_REGION", minimax.RegionGlobalEN, minimax.RegionGlobalEN, minimax.RegionCNZH) + if err != nil { + return Config{}, err + } + miniMaxEndpoints, ok := minimax.EndpointsForRegion(miniMaxRegion) + if !ok { + return Config{}, fmt.Errorf("MINIMAX_REGION is not supported") + } + miniMaxAPIKey := envString("MINIMAX_API_KEY", "") + configuredDefaultModel := envString("GROK2API_DEFAULT_MODEL", defaultModel) + if miniMaxAPIKey != "" { + configuredDefaultModel = minimax.ResolveModel(configuredDefaultModel, minimax.DefaultModel) + if !minimax.IsModel(configuredDefaultModel) { + configuredDefaultModel = minimax.DefaultModel + } + } storeBackend, err := envEnum("GROK2API_STORE_BACKEND", "hybrid", "hybrid", "file") if err != nil { @@ -187,8 +209,12 @@ func Load() (Config, error) { return Config{ Host: envString("GROK2API_HOST", defaultHost), Port: port, - DefaultModel: envString("GROK2API_DEFAULT_MODEL", defaultModel), + DefaultModel: configuredDefaultModel, UpstreamBase: strings.TrimRight(envString("GROK_CLI_CHAT_PROXY_BASE_URL", defaultUpstream), "/"), + MiniMaxAPIKey: miniMaxAPIKey, + MiniMaxRegion: miniMaxRegion, + MiniMaxOpenAIBaseURL: strings.TrimRight(envString("MINIMAX_OPENAI_BASE_URL", miniMaxEndpoints.OpenAIBaseURL), "/"), + MiniMaxAnthropicBaseURL: strings.TrimRight(envString("MINIMAX_ANTHROPIC_BASE_URL", miniMaxEndpoints.AnthropicBaseURL), "/"), RedisURL: envAlias([]string{"GROK2API_REDIS_URL", "REDIS_URL"}, defaultRedisURL), DatabaseURL: envAlias([]string{"GROK2API_DATABASE_URL", "DATABASE_URL"}, defaultDatabaseURL), RedisPrefix: envString("GROK2API_REDIS_PREFIX", defaultRedisPrefix), @@ -233,6 +259,10 @@ func (c Config) Address() string { return fmt.Sprintf("%s:%d", c.Host, c.Port) } +func (c Config) MiniMaxEnabled() bool { + return strings.TrimSpace(c.MiniMaxAPIKey) != "" +} + func envString(name, fallback string) string { if value := strings.TrimSpace(os.Getenv(name)); value != "" { return value diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5a77b8ce..02de78a7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,6 +11,10 @@ func TestLoadDefaults(t *testing.T) { "GROK2API_PORT", "GROK2API_DEFAULT_MODEL", "GROK_CLI_CHAT_PROXY_BASE_URL", + "MINIMAX_API_KEY", + "MINIMAX_REGION", + "MINIMAX_OPENAI_BASE_URL", + "MINIMAX_ANTHROPIC_BASE_URL", "GROK2API_REDIS_URL", "REDIS_URL", "GROK2API_DATABASE_URL", @@ -50,6 +54,9 @@ func TestLoadDefaults(t *testing.T) { if cfg.DefaultModel != "grok-4.5" { t.Fatalf("unexpected model %q", cfg.DefaultModel) } + if cfg.MiniMaxEnabled() { + t.Fatal("MiniMax must be disabled without an API key") + } if cfg.SSEKeepalive != 4*time.Second { t.Fatalf("unexpected keepalive %s", cfg.SSEKeepalive) } @@ -133,6 +140,32 @@ func TestLoadRejectsInvalidValues(t *testing.T) { } } +func TestLoadMiniMaxRegionAndDefaults(t *testing.T) { + t.Setenv("MINIMAX_API_KEY", "configured") + t.Setenv("MINIMAX_REGION", "cn_zh") + t.Setenv("GROK2API_DEFAULT_MODEL", "") + t.Setenv("MINIMAX_OPENAI_BASE_URL", "") + t.Setenv("MINIMAX_ANTHROPIC_BASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if !cfg.MiniMaxEnabled() || cfg.DefaultModel != "MiniMax-M3" { + t.Fatalf("MiniMax configuration was not enabled: %#v", cfg) + } + if cfg.MiniMaxOpenAIBaseURL != "https://api.minimaxi.com/v1" || cfg.MiniMaxAnthropicBaseURL != "https://api.minimaxi.com/anthropic" { + t.Fatalf("regional endpoints=%q %q", cfg.MiniMaxOpenAIBaseURL, cfg.MiniMaxAnthropicBaseURL) + } +} + +func TestLoadRejectsInvalidMiniMaxRegion(t *testing.T) { + t.Setenv("MINIMAX_REGION", "invalid") + if _, err := Load(); err == nil { + t.Fatal("expected invalid MiniMax region error") + } +} + func TestLoadRejectsInvalidEnum(t *testing.T) { t.Setenv("GROK2API_GO_OWNERSHIP_MODE", "maybe") if _, err := Load(); err == nil { diff --git a/internal/models/catalog.go b/internal/models/catalog.go index 85d30f30..3d7c9e96 100644 --- a/internal/models/catalog.go +++ b/internal/models/catalog.go @@ -8,6 +8,7 @@ import ( "github.com/hm2899/grokcli-2api/internal/config" "github.com/hm2899/grokcli-2api/internal/store/postgres" + "github.com/hm2899/grokcli-2api/internal/upstream/minimax" ) type Catalog struct { @@ -24,6 +25,9 @@ func (c *Catalog) OpenAIList(ctx context.Context) map[string]any { } func (c *Catalog) PublicModels(ctx context.Context) []map[string]any { + if c != nil && c.cfg.MiniMaxEnabled() { + return minimax.CatalogEntries(time.Now().Unix()) + } if c != nil && c.store != nil { rows, err := c.store.ListModels(ctx, false) if err == nil && len(rows) > 0 { @@ -47,6 +51,9 @@ func (c *Catalog) PublicModels(ctx context.Context) []map[string]any { func (c *Catalog) Resolve(model string) string { m := strings.TrimSpace(model) + if c != nil && c.cfg.MiniMaxEnabled() { + return minimax.ResolveModel(m, c.defaultModel()) + } if m == "" { return c.defaultModel() } @@ -64,6 +71,9 @@ func (c *Catalog) Resolve(model string) string { } func (c *Catalog) defaultModel() string { + if c != nil && c.cfg.MiniMaxEnabled() && strings.TrimSpace(c.cfg.DefaultModel) == "" { + return minimax.DefaultModel + } if c == nil || strings.TrimSpace(c.cfg.DefaultModel) == "" { return "grok-4.5" } diff --git a/internal/models/catalog_test.go b/internal/models/catalog_test.go index 47904c2f..4188b1f5 100644 --- a/internal/models/catalog_test.go +++ b/internal/models/catalog_test.go @@ -36,3 +36,24 @@ func TestResolveAliases(t *testing.T) { } } } + +func TestMiniMaxCatalogAndAliases(t *testing.T) { + catalog := NewCatalog(config.Config{MiniMaxAPIKey: "configured"}, nil) + items := catalog.PublicModels(t.Context()) + if len(items) != 2 { + t.Fatalf("models=%#v", items) + } + if items[0]["id"] != "MiniMax-M3" || items[0]["owned_by"] != "MiniMax" || items[0]["context_window"] != 1_000_000 { + t.Fatalf("primary model=%#v", items[0]) + } + modalities, _ := items[0]["input_modalities"].([]string) + if len(modalities) != 3 || modalities[1] != "image" || modalities[2] != "video" { + t.Fatalf("modalities=%#v", modalities) + } + if got := catalog.Resolve("minimax-latest"); got != "MiniMax-M3" { + t.Fatalf("latest alias=%q", got) + } + if got := catalog.Resolve("minimax-m2.7"); got != "MiniMax-M2.7" { + t.Fatalf("model alias=%q", got) + } +} diff --git a/internal/proxy/chat.go b/internal/proxy/chat.go index 85d13678..e3d4eea8 100644 --- a/internal/proxy/chat.go +++ b/internal/proxy/chat.go @@ -31,7 +31,7 @@ type AccountFailureReporter interface { type ChatService struct { Catalog *models.Catalog - Client *grok.Client + Client grok.Opener Now func() time.Time PickObserver PickObserver AffinityStore AffinityStore @@ -146,7 +146,7 @@ func (s *ChatService) CompleteWithResult(ctx context.Context, request ChatReques return ChatResult{}, err } accounts := upstreamAccounts(chain) - body, prep := PrepareUpstreamBodyDetailed(request.Raw, request.UserAgent) + body, prep := PrepareUpstreamBodyDetailed(request.Raw, request.UserAgent) ensureUpstreamCacheKey(body, request) fingerprint := ChatFingerprint(request) // Prefer account already boosted to chain[0] by prepareChain/ensureStickyCandidate. @@ -249,7 +249,7 @@ func (s *ChatService) OpenStreamWithResult(ctx context.Context, request ChatRequ return StreamOpen{}, err } accounts := upstreamAccounts(chain) - body, prep := PrepareUpstreamBodyDetailed(request.Raw, request.UserAgent) + body, prep := PrepareUpstreamBodyDetailed(request.Raw, request.UserAgent) ensureUpstreamCacheKey(body, request) fingerprint := ChatFingerprint(request) // Prefer account already boosted to chain[0] by prepareChain/ensureStickyCandidate. @@ -412,7 +412,7 @@ func (s *ChatService) OpenStreamWithResult(ctx context.Context, request ChatRequ func (s *ChatService) parallelFirstByteOpen( ctx context.Context, accounts []grok.Account, - client *grok.Client, + client grok.Opener, model string, body map[string]any, chain []pool.Candidate, @@ -607,7 +607,7 @@ func ForwardChatStreamWithStats(reader io.Reader, emit func(StreamFrame) error) return stats, err } -func (s *ChatService) prepare(ctx context.Context, request ChatRequest, candidates []pool.Candidate, mode string) (string, pool.Candidate, *grok.Client, error) { +func (s *ChatService) prepare(ctx context.Context, request ChatRequest, candidates []pool.Candidate, mode string) (string, pool.Candidate, grok.Opener, error) { model := s.resolveModel(request) now := time.Now() if s.Now != nil { @@ -639,7 +639,7 @@ func (s *ChatService) prepare(ctx context.Context, request ChatRequest, candidat // defaultFailoverChain matches Python MAX_FAILOVER_ATTEMPTS (short sticky-friendly chain). const defaultFailoverChain = 12 -func (s *ChatService) prepareChain(ctx context.Context, request ChatRequest, candidates []pool.Candidate, mode string) (string, []pool.Candidate, *grok.Client, error) { +func (s *ChatService) prepareChain(ctx context.Context, request ChatRequest, candidates []pool.Candidate, mode string) (string, []pool.Candidate, grok.Opener, error) { model := s.resolveModel(request) now := time.Now() if s.Now != nil { @@ -1777,7 +1777,6 @@ func (s *ChatService) clearStickyPins(ctx context.Context, request ChatRequest) } } - // shouldDropStickyPin reports whether a sticky-primary open failure is durable // enough to abandon the multi-turn pin. Transient network/timeouts keep the pin // so the next turn can retry the cache-warm account (avoids intermittent cache @@ -1930,7 +1929,6 @@ func upstreamAccounts(chain []pool.Candidate) []grok.Account { return accounts } - // ensureUpstreamCacheKey guarantees body.prompt_cache_key survives Stabilize/Sanitize // so cli-chat-proxy /responses receives the same key that affinity used. Without this, // intermittent strip/empty pck makes x-grok-conv-id empty and cache hits go cold. diff --git a/internal/proxy/failover.go b/internal/proxy/failover.go index 22f86825..3d8460f3 100644 --- a/internal/proxy/failover.go +++ b/internal/proxy/failover.go @@ -29,7 +29,7 @@ type Attempt struct { // and only before the caller commits model content or tool payload to the client. func OpenWithFailover( ctx context.Context, - client *grok.Client, + client grok.Opener, accounts []grok.Account, model string, body map[string]any, diff --git a/internal/server/native_messages.go b/internal/server/native_messages.go new file mode 100644 index 00000000..68837c96 --- /dev/null +++ b/internal/server/native_messages.go @@ -0,0 +1,192 @@ +package server + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/hm2899/grokcli-2api/internal/auth" + "github.com/hm2899/grokcli-2api/internal/pool" + "github.com/hm2899/grokcli-2api/internal/upstream/grok" +) + +type nativeMessagesUpstream interface { + OpenMessages(context.Context, grok.Account, string, map[string]any, http.Header) (*http.Response, error) +} + +func serveNativeMessages(w http.ResponseWriter, r *http.Request, options Options, apiKey *auth.APIKeyRecord, raw map[string]any, model string, stream bool) bool { + client, ok := upstreamClient(options).(nativeMessagesUpstream) + if !ok { + return false + } + + started := time.Now() + candidates, err := listCandidates(r.Context(), options) + if err != nil { + writeAnthropicError(w, http.StatusInternalServerError, err.Error(), "api_error") + return true + } + picked, err := pool.Pick(candidates, model, resolvePickMode(options), time.Now()) + if err != nil { + writeAnthropicProxyError(w, err) + return true + } + + response, err := client.OpenMessages(r.Context(), picked.UpstreamAccount(), model, raw, r.Header) + if err != nil { + recordAnthropicUsage(r, options, apiKey, picked.ID, model, stream, false, http.StatusBadGateway, started, nil, err, 0, raw) + reportChatPool(r, options, picked.ID, false, err, http.StatusBadGateway, model) + writeAnthropicProxyError(w, err) + return true + } + defer response.Body.Close() + copyNativeMessagesHeaders(w.Header(), response.Header, stream) + + if stream { + w.WriteHeader(response.StatusCode) + usage, firstTokenMS, relayErr := relayNativeMessagesStream(w, response.Body) + requestOK := relayErr == nil || errors.Is(relayErr, r.Context().Err()) + status := response.StatusCode + if !requestOK { + status = http.StatusBadGateway + } + recordAnthropicUsage(r, options, apiKey, picked.ID, model, true, requestOK, status, started, usage, relayErr, firstTokenMS, raw) + reportChatPool(r, options, picked.ID, requestOK, relayErr, status, model) + return true + } + + payload, err := io.ReadAll(io.LimitReader(response.Body, 32<<20)) + if err != nil { + recordAnthropicUsage(r, options, apiKey, picked.ID, model, false, false, http.StatusBadGateway, started, nil, err, 0, raw) + writeAnthropicProxyError(w, err) + return true + } + var decoded map[string]any + _ = json.Unmarshal(payload, &decoded) + usage, _ := decoded["usage"].(map[string]any) + if returnedModel, _ := decoded["model"].(string); strings.TrimSpace(returnedModel) != "" { + model = strings.TrimSpace(returnedModel) + } + w.WriteHeader(response.StatusCode) + _, writeErr := w.Write(payload) + requestOK := writeErr == nil + recordAnthropicUsage(r, options, apiKey, picked.ID, model, false, requestOK, response.StatusCode, started, usage, writeErr, 0, raw) + reportChatPool(r, options, picked.ID, requestOK, writeErr, response.StatusCode, model) + return true +} + +func copyNativeMessagesHeaders(dst, src http.Header, stream bool) { + contentType := strings.TrimSpace(src.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + if stream { + contentType = "text/event-stream" + } + } + dst.Set("Content-Type", contentType) + for _, name := range []string{"request-id", "x-request-id"} { + if value := strings.TrimSpace(src.Get(name)); value != "" { + dst.Set(name, value) + } + } +} + +func relayNativeMessagesStream(w http.ResponseWriter, body io.Reader) (map[string]any, int, error) { + reader := bufio.NewReader(body) + flusher, _ := w.(http.Flusher) + usage := map[string]any{} + started := time.Now() + firstTokenMS := 0 + + for { + line, err := reader.ReadString('\n') + if line != "" { + if data, ok := nativeMessagesData(line); ok { + if mergeNativeMessagesEvent(usage, data) && firstTokenMS == 0 { + firstTokenMS = int(time.Since(started).Milliseconds()) + if firstTokenMS <= 0 { + firstTokenMS = 1 + } + } + } + if _, writeErr := io.WriteString(w, line); writeErr != nil { + return finalizeNativeMessagesUsage(usage), firstTokenMS, writeErr + } + if flusher != nil { + flusher.Flush() + } + } + if err == nil { + continue + } + if errors.Is(err, io.EOF) { + return finalizeNativeMessagesUsage(usage), firstTokenMS, nil + } + return finalizeNativeMessagesUsage(usage), firstTokenMS, err + } +} + +func nativeMessagesData(line string) ([]byte, bool) { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + return nil, false + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + return nil, false + } + return []byte(data), true +} + +func mergeNativeMessagesEvent(usage map[string]any, data []byte) bool { + var event map[string]any + if err := json.Unmarshal(data, &event); err != nil { + return false + } + if message, _ := event["message"].(map[string]any); message != nil { + if values, _ := message["usage"].(map[string]any); values != nil { + mergeNativeMessagesUsage(usage, values) + } + } + if values, _ := event["usage"].(map[string]any); values != nil { + mergeNativeMessagesUsage(usage, values) + } + eventType, _ := event["type"].(string) + if eventType == "content_block_start" { + return true + } + if eventType != "content_block_delta" { + return false + } + delta, _ := event["delta"].(map[string]any) + deltaType, _ := delta["type"].(string) + switch deltaType { + case "text_delta", "thinking_delta", "input_json_delta": + return true + default: + return false + } +} + +func mergeNativeMessagesUsage(dst, src map[string]any) { + for key, value := range src { + if value != nil { + dst[key] = value + } + } +} + +func finalizeNativeMessagesUsage(usage map[string]any) map[string]any { + if len(usage) == 0 { + return nil + } + if anyToInt64(usage["total_tokens"]) == 0 { + usage["total_tokens"] = anyToInt64(usage["input_tokens"]) + anyToInt64(usage["output_tokens"]) + } + return usage +} diff --git a/internal/server/native_messages_test.go b/internal/server/native_messages_test.go new file mode 100644 index 00000000..a7df3c78 --- /dev/null +++ b/internal/server/native_messages_test.go @@ -0,0 +1,57 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/hm2899/grokcli-2api/internal/config" + "github.com/hm2899/grokcli-2api/internal/models" + "github.com/hm2899/grokcli-2api/internal/pool" + "github.com/hm2899/grokcli-2api/internal/upstream/minimax" +) + +func TestMiniMaxMessagesUsesNativeEndpoint(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/anthropic/v1/messages" { + t.Fatalf("path=%s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Fatalf("authorization=%q", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"msg_1","type":"message","role":"assistant","model":"MiniMax-M3","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":4,"output_tokens":2}}`) + })) + defer upstream.Close() + + cfg := config.Config{MiniMaxAPIKey: "configured", DefaultModel: minimax.DefaultModel} + handler := NewMux(Options{ + Ready: func() bool { return true }, + MessagesEnabled: true, + Models: models.NewCatalog(cfg, nil), + Candidates: []pool.Candidate{{ + ID: "minimax", + Token: "test-token", + Enabled: true, + }}, + Upstream: &minimax.Client{ + AnthropicBaseURL: upstream.URL + "/anthropic", + HTTP: upstream.Client(), + }, + Config: cfg, + }) + + request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"MiniMax-M3","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`)) + request.Header.Set("anthropic-version", "2023-06-01") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), `"text":"hello"`) || !strings.Contains(recorder.Body.String(), `"model":"MiniMax-M3"`) { + t.Fatalf("body=%s", recorder.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b8a50c5d..ba289869 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -64,9 +64,9 @@ type Options struct { AdminSessions admin.SessionVerifier PickObserver proxy.PickObserver AffinityStore proxy.AffinityStore - // Upstream is a shared Grok HTTP client (connection pool). Prefer this over + // Upstream is a shared HTTP client (connection pool). Prefer this over // constructing a new client on every request. - Upstream *grok.Client + Upstream grok.Opener Redis *redis.Client Leader *redis.Leader Maintainer *maintainer.Service @@ -1378,6 +1378,9 @@ func reportChatPool(r *http.Request, options Options, accountID string, ok bool, if strings.TrimSpace(accountID) == "" { return } + if options.Config.MiniMaxEnabled() { + return + } modelHint := "" if len(requestedModel) > 0 { modelHint = strings.TrimSpace(requestedModel[0]) @@ -1688,6 +1691,9 @@ func serveMessages(w http.ResponseWriter, r *http.Request, options Options) { } stream, _ := raw["stream"].(bool) model := modelCatalog(options).Resolve(stringValue(raw["model"])) + if serveNativeMessages(w, r, options, apiKey, raw, model, stream) { + return + } body, err := anthropic.BuildOpenAIChatBody(raw, model) if err != nil { writeAnthropicError(w, http.StatusBadRequest, err.Error(), "invalid_request_error") @@ -6374,13 +6380,27 @@ func firstNonEmpty(values ...string) string { return "" } -func upstreamClient(options Options) *grok.Client { +func upstreamClient(options Options) grok.Opener { if options.Upstream != nil { return options.Upstream } return &grok.Client{BaseURL: options.Config.UpstreamBase} } +type upstreamModelsEndpoint interface { + ModelsURL() string +} + +func upstreamModelsURL(options Options) string { + client := upstreamClient(options) + if endpoint, ok := client.(upstreamModelsEndpoint); ok { + if value := strings.TrimSpace(endpoint.ModelsURL()); value != "" { + return value + } + } + return strings.TrimRight(strings.TrimSpace(options.Config.UpstreamBase), "/") + "/models" +} + // resolvePickMode returns the configured account rotation mode for failover chains. // Sticky accounts are still forced first; mode only ranks the rest of the window. func resolvePickMode(options Options) string { @@ -7711,50 +7731,65 @@ func serveAdminNormalizeAccounts(w http.ResponseWriter, r *http.Request, options writeJSON(w, http.StatusOK, result) } -// fetchUpstreamModels pulls /models from xAI using a live account token. +// fetchUpstreamModels pulls /models using the configured upstream credentials. // Does NOT write the database — callers preview or save separately. func fetchUpstreamModels(ctx context.Context, options Options) (items []map[string]any, viaEmail string, origin string, err error) { - if options.Store == nil { - return nil, "", "", errors.New("store unavailable") - } - authList, err := options.Store.ListAccountAuths(ctx, 20, true) - if err != nil { - return nil, "", "", err + token := "" + if len(options.Candidates) > 0 { + for _, candidate := range options.Candidates { + if strings.TrimSpace(candidate.Token) != "" { + token = candidate.Token + viaEmail = candidate.ID + break + } + } + } else if options.Store != nil { + authList, listErr := options.Store.ListAccountAuths(ctx, 20, true) + if listErr != nil { + return nil, "", "", listErr + } + if len(authList) > 0 { + token = authList[0].Token + viaEmail = authList[0].Email + } } - if len(authList) == 0 { + if strings.TrimSpace(token) == "" { return nil, "", "", errors.New("no live account for models fetch") } - a := authList[0] - origin = strings.TrimRight(options.Config.UpstreamBase, "/") + "/models" + origin = upstreamModelsURL(options) client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequestWithContext(ctx, http.MethodGet, origin, nil) if err != nil { return nil, "", origin, err } gc := upstreamClient(options) - for k, v := range gc.Headers(a.Token, options.runtimeConfig().DefaultModel) { + for k, v := range gc.Headers(token, options.runtimeConfig().DefaultModel) { req.Header.Set(k, v) } resp, err := client.Do(req) if err != nil { - return nil, a.Email, origin, err + return nil, viaEmail, origin, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if resp.StatusCode >= 400 { - return nil, a.Email, origin, fmt.Errorf("upstream %d: %s", resp.StatusCode, string(body)[:minInt(300, len(body))]) + return nil, viaEmail, origin, fmt.Errorf("upstream %d: %s", resp.StatusCode, string(body)[:minInt(300, len(body))]) } var payload any if err := json.Unmarshal(body, &payload); err != nil { - return nil, a.Email, origin, fmt.Errorf("parse: %w", err) + return nil, viaEmail, origin, fmt.Errorf("parse: %w", err) } items = parseUpstreamModels(payload) - // Always keep local extras (coding/build/search aliases) even when upstream list is tiny. - items = ensureLocalModelExtras(items, options.runtimeConfig().DefaultModel) + if options.Config.MiniMaxEnabled() { + items = modelCatalog(options).PublicModels(ctx) + } else { + // Always keep local extras (coding/build/search aliases) even when upstream list is tiny. + items = ensureLocalModelExtras(items, options.runtimeConfig().DefaultModel) + } if len(items) == 0 { - return nil, a.Email, origin, errors.New("no models in upstream response") + return nil, viaEmail, origin, errors.New("no models in upstream response") } - return items, a.Email, origin, nil + return items, viaEmail, origin, nil } // serveAdminModelsFetch: 仅从上游获取模型列表(预览,不写库)。 diff --git a/internal/server/upstream_status.go b/internal/server/upstream_status.go index 09977f9c..c6b859c8 100644 --- a/internal/server/upstream_status.go +++ b/internal/server/upstream_status.go @@ -112,7 +112,8 @@ func probeUpstreamStatus(ctx context.Context, options Options, force bool) map[s } func doUpstreamProbe(ctx context.Context, options Options) map[string]any { - base := strings.TrimRight(strings.TrimSpace(options.Config.UpstreamBase), "/") + origin := upstreamModelsURL(options) + base := strings.TrimSuffix(strings.TrimRight(strings.TrimSpace(origin), "/"), "/models") now := time.Now() out := map[string]any{ "ok": false, @@ -126,7 +127,6 @@ func doUpstreamProbe(ctx context.Context, options Options) map[string]any { out["error"] = "upstream base not configured" return out } - origin := base + "/models" out["origin"] = origin // 1) TCP/TLS reachability (host:port from URL). @@ -152,7 +152,15 @@ func doUpstreamProbe(ctx context.Context, options Options) map[string]any { // 2) HTTP GET /models — with live account token when available. viaEmail := "" token := "" - if options.Store != nil { + if len(options.Candidates) > 0 { + for _, candidate := range options.Candidates { + if strings.TrimSpace(candidate.Token) != "" { + token = candidate.Token + viaEmail = candidate.ID + break + } + } + } else if options.Store != nil { if auths, err := options.Store.ListAccountAuths(ctx, 5, true); err == nil && len(auths) > 0 { // Prefer a non-empty token. for _, a := range auths { diff --git a/internal/upstream/grok/client.go b/internal/upstream/grok/client.go index 0ab7c437..baa32874 100644 --- a/internal/upstream/grok/client.go +++ b/internal/upstream/grok/client.go @@ -27,6 +27,11 @@ type Account struct { Token string } +type Opener interface { + Open(context.Context, Account, string, map[string]any) (*http.Response, error) + Headers(string, string, ...string) map[string]string +} + type Event struct { Data []byte Done bool @@ -170,6 +175,10 @@ func (c *Client) Headers(token, model string, convID ...string) map[string]strin return out } +func (c *Client) ModelsURL() string { + return strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") + "/models" +} + // extractConvID picks a stable session/cache key from the upstream body for // x-grok-conv-id. Preference matches CPA: prompt_cache_key first. func extractConvID(body map[string]any) string { diff --git a/internal/upstream/minimax/client.go b/internal/upstream/minimax/client.go new file mode 100644 index 00000000..1137915d --- /dev/null +++ b/internal/upstream/minimax/client.go @@ -0,0 +1,155 @@ +package minimax + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/hm2899/grokcli-2api/internal/upstream/grok" +) + +type Client struct { + OpenAIBaseURL string + AnthropicBaseURL string + HTTP *http.Client +} + +func (c *Client) Headers(token, _ string, _ ...string) map[string]string { + return map[string]string{ + "Authorization": "Bearer " + strings.TrimSpace(token), + "Content-Type": "application/json", + "Accept": "application/json", + } +} + +func (c *Client) ModelsURL() string { + baseURL := strings.TrimRight(strings.TrimSpace(c.OpenAIBaseURL), "/") + if baseURL == "" { + defaults, _ := EndpointsForRegion(RegionGlobalEN) + baseURL = defaults.OpenAIBaseURL + } + return baseURL + "/models" +} + +func (c *Client) Open(ctx context.Context, account grok.Account, model string, body map[string]any) (*http.Response, error) { + payload := cloneMap(body) + payload["model"] = ResolveModel(model, DefaultModel) + payload["stream"] = true + if _, ok := payload["reasoning_split"]; !ok { + payload["reasoning_split"] = true + } + delete(payload, "prompt_cache_key") + delete(payload, "prompt_cache_retention") + + if payload["max_completion_tokens"] == nil && payload["max_tokens"] != nil { + payload["max_completion_tokens"] = payload["max_tokens"] + } + delete(payload, "max_tokens") + normalizeThinking(payload) + + streamOptions, _ := payload["stream_options"].(map[string]any) + if streamOptions == nil { + streamOptions = map[string]any{} + } + streamOptions["include_usage"] = true + payload["stream_options"] = streamOptions + + baseURL := strings.TrimRight(strings.TrimSpace(c.OpenAIBaseURL), "/") + if baseURL == "" { + defaults, _ := EndpointsForRegion(RegionGlobalEN) + baseURL = defaults.OpenAIBaseURL + } + return c.postJSON(ctx, account.Token, baseURL+"/chat/completions", payload, map[string]string{ + "Accept": "text/event-stream", + }) +} + +func (c *Client) OpenMessages(ctx context.Context, account grok.Account, model string, body map[string]any, headers http.Header) (*http.Response, error) { + payload := cloneMap(body) + payload["model"] = ResolveModel(model, DefaultModel) + + baseURL := strings.TrimRight(strings.TrimSpace(c.AnthropicBaseURL), "/") + if baseURL == "" { + defaults, _ := EndpointsForRegion(RegionGlobalEN) + baseURL = defaults.AnthropicBaseURL + } + extraHeaders := map[string]string{ + "Accept": "application/json", + "anthropic-version": strings.TrimSpace(headers.Get("anthropic-version")), + "anthropic-beta": strings.TrimSpace(headers.Get("anthropic-beta")), + } + if extraHeaders["anthropic-version"] == "" { + extraHeaders["anthropic-version"] = "2023-06-01" + } + if stream, _ := payload["stream"].(bool); stream { + extraHeaders["Accept"] = "text/event-stream" + } + return c.postJSON(ctx, account.Token, baseURL+"/v1/messages", payload, extraHeaders) +} + +func (c *Client) postJSON(ctx context.Context, token, url string, payload map[string]any, headers map[string]string) (*http.Response, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded)) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + request.Header.Set("Content-Type", "application/json") + for name, value := range headers { + if strings.TrimSpace(value) != "" { + request.Header.Set(name, value) + } + } + + httpClient := c.HTTP + if httpClient == nil { + httpClient = http.DefaultClient + } + response, err := httpClient.Do(request) + if err != nil { + return nil, err + } + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + return response, nil + } + defer response.Body.Close() + errBody, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + return nil, &grok.UpstreamError{ + Status: response.StatusCode, + Body: string(errBody), + RetryAfter: response.Header.Get("Retry-After"), + } +} + +func normalizeThinking(payload map[string]any) { + if payload["thinking"] != nil { + delete(payload, "reasoning_effort") + return + } + effort, _ := payload["reasoning_effort"].(string) + effort = strings.ToLower(strings.TrimSpace(effort)) + delete(payload, "reasoning_effort") + if effort == "" { + return + } + thinkingType := "adaptive" + switch effort { + case "none", "off", "disabled": + thinkingType = "disabled" + } + payload["thinking"] = map[string]any{"type": thinkingType} +} + +func cloneMap(input map[string]any) map[string]any { + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out +} diff --git a/internal/upstream/minimax/client_test.go b/internal/upstream/minimax/client_test.go new file mode 100644 index 00000000..3cdbb592 --- /dev/null +++ b/internal/upstream/minimax/client_test.go @@ -0,0 +1,144 @@ +package minimax + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/hm2899/grokcli-2api/internal/upstream/grok" +) + +func TestClientOpenUsesChatCompletions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Fatalf("path=%s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("authorization=%q", got) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload["model"] != DefaultModel || payload["stream"] != true || payload["reasoning_split"] != true { + t.Fatalf("payload=%#v", payload) + } + if payload["max_completion_tokens"] != float64(64) || payload["max_tokens"] != nil { + t.Fatalf("token fields=%#v", payload) + } + if payload["prompt_cache_key"] != nil { + t.Fatalf("prompt_cache_key should be local-only: %#v", payload) + } + thinking, _ := payload["thinking"].(map[string]any) + if thinking["type"] != "adaptive" { + t.Fatalf("thinking=%#v", thinking) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n") + })) + defer server.Close() + + client := &Client{OpenAIBaseURL: server.URL + "/v1", HTTP: server.Client()} + response, err := client.Open(context.Background(), grok.Account{ID: "minimax", Token: "test-token"}, DefaultModel, map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + "max_tokens": 64, + "reasoning_effort": "high", + "prompt_cache_key": "local-cache-key", + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"content":"ok"`) { + t.Fatalf("body=%s", body) + } +} + +func TestClientOpenPreservesReasoningSplit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload["reasoning_split"] != false { + t.Fatalf("reasoning_split=%#v", payload["reasoning_split"]) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + defer server.Close() + + client := &Client{OpenAIBaseURL: server.URL + "/v1", HTTP: server.Client()} + response, err := client.Open(context.Background(), grok.Account{Token: "test-token"}, DefaultModel, map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + "reasoning_split": false, + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() +} + +func TestClientOpenMessagesUsesAnthropicEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/anthropic/v1/messages" { + t.Fatalf("path=%s", r.URL.Path) + } + if r.Header.Get("anthropic-version") != "2023-06-01" || r.Header.Get("anthropic-beta") != "prompt-caching" { + t.Fatalf("headers=%v", r.Header) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload["model"] != ModelM27 { + t.Fatalf("model=%#v", payload["model"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"type":"message","model":"MiniMax-M2.7","content":[{"type":"text","text":"ok"}]}`) + })) + defer server.Close() + + client := &Client{AnthropicBaseURL: server.URL + "/anthropic", HTTP: server.Client()} + headers := http.Header{} + headers.Set("anthropic-beta", "prompt-caching") + response, err := client.OpenMessages(context.Background(), grok.Account{ID: "minimax", Token: "test-token"}, ModelM27, map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + }, headers) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() +} + +func TestEndpointsCatalogAndAliases(t *testing.T) { + endpoints, ok := EndpointsForRegion(RegionCNZH) + if !ok { + t.Fatal("CN region missing") + } + if endpoints.OpenAIBaseURL != "https://api.minimaxi.com/v1" || endpoints.AnthropicBaseURL != "https://api.minimaxi.com/anthropic" { + t.Fatalf("endpoints=%#v", endpoints) + } + if ResolveModel("minimax-m2.7", DefaultModel) != ModelM27 || ResolveModel("minimax-latest", DefaultModel) != DefaultModel { + t.Fatal("model aliases did not resolve") + } + entries := CatalogEntries(123) + if len(entries) != 2 { + t.Fatalf("entries=%#v", entries) + } + if entries[0]["context_window"] != 1_000_000 { + t.Fatalf("first entry=%#v", entries[0]) + } + pricing, _ := entries[1]["pricing_usd_per_million_tokens"].(map[string]any) + if pricing["cache_write"] != 0.375 { + t.Fatalf("pricing=%#v", pricing) + } +} diff --git a/internal/upstream/minimax/provider.go b/internal/upstream/minimax/provider.go new file mode 100644 index 00000000..383da863 --- /dev/null +++ b/internal/upstream/minimax/provider.go @@ -0,0 +1,135 @@ +package minimax + +import "strings" + +const ( + ProviderName = "MiniMax" + DefaultModel = "MiniMax-M3" + ModelM27 = "MiniMax-M2.7" + + RegionGlobalEN = "global_en" + RegionCNZH = "cn_zh" +) + +type Endpoints struct { + OpenAIBaseURL string + AnthropicBaseURL string +} + +type modelSpec struct { + id string + contextWindow int + inputPrice float64 + outputPrice float64 + cacheReadPrice float64 + cacheWrite *float64 + modalities []string + thinking []string +} + +var modelSpecs = []modelSpec{ + { + id: DefaultModel, + contextWindow: 1_000_000, + inputPrice: 0.6, + outputPrice: 2.4, + cacheReadPrice: 0.12, + modalities: []string{"text", "image", "video"}, + thinking: []string{"adaptive", "disabled"}, + }, + { + id: ModelM27, + contextWindow: 204_800, + inputPrice: 0.3, + outputPrice: 1.2, + cacheReadPrice: 0.06, + cacheWrite: float64Ptr(0.375), + modalities: []string{"text"}, + thinking: []string{"always_on"}, + }, +} + +func EndpointsForRegion(region string) (Endpoints, bool) { + switch strings.ToLower(strings.TrimSpace(region)) { + case "", RegionGlobalEN: + return Endpoints{ + OpenAIBaseURL: "https://api.minimax.io/v1", + AnthropicBaseURL: "https://api.minimax.io/anthropic", + }, true + case RegionCNZH: + return Endpoints{ + OpenAIBaseURL: "https://api.minimaxi.com/v1", + AnthropicBaseURL: "https://api.minimaxi.com/anthropic", + }, true + default: + return Endpoints{}, false + } +} + +func ModelIDs() []string { + return []string{DefaultModel, ModelM27} +} + +func IsModel(model string) bool { + switch ResolveModel(model, DefaultModel) { + case DefaultModel, ModelM27: + return true + default: + return false + } +} + +func ResolveModel(model, fallback string) string { + model = strings.TrimSpace(model) + if model == "" { + return canonicalFallback(fallback) + } + switch strings.ToLower(model) { + case "minimax", "minimax-latest", strings.ToLower(DefaultModel): + return DefaultModel + case strings.ToLower(ModelM27): + return ModelM27 + default: + return model + } +} + +func CatalogEntries(created int64) []map[string]any { + entries := make([]map[string]any, 0, len(modelSpecs)) + for _, spec := range modelSpecs { + var cacheWrite any + if spec.cacheWrite != nil { + cacheWrite = *spec.cacheWrite + } + entries = append(entries, map[string]any{ + "id": spec.id, + "name": spec.id, + "object": "model", + "created": created, + "owned_by": ProviderName, + "context_window": spec.contextWindow, + "pricing_usd_per_million_tokens": map[string]any{ + "input": spec.inputPrice, + "output": spec.outputPrice, + "cache_read": spec.cacheReadPrice, + "cache_write": cacheWrite, + }, + "input_modalities": append([]string(nil), spec.modalities...), + "thinking": append([]string(nil), spec.thinking...), + }) + } + return entries +} + +func canonicalFallback(fallback string) string { + switch strings.ToLower(strings.TrimSpace(fallback)) { + case strings.ToLower(ModelM27): + return ModelM27 + default: + return DefaultModel + } +} + +func float64Ptr(value float64) *float64 { + return &value +}