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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions cmd/grok2api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
35 changes: 34 additions & 1 deletion contracts/env-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 31 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"time"

"github.com/hm2899/grokcli-2api/internal/upstream/minimax"
)

const (
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions internal/models/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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()
}
Expand All @@ -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"
}
Expand Down
21 changes: 21 additions & 0 deletions internal/models/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
14 changes: 6 additions & 8 deletions internal/proxy/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/proxy/failover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading