From b5d5fce6e46aea9befb968cf599e3246ef99b35d Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Sun, 21 Jun 2026 16:39:14 +0700 Subject: [PATCH] feat: update MiniMax and Z.AI provider defaults Refresh MiniMax to MiniMax-M3, update Z.AI defaults to glm-5.2, and add focused provider catalog/runtime coverage. --- cmd/gateway_providers.go | 41 ++++-- cmd/gateway_providers_defaults_test.go | 156 +++++++++++++++++++++++ docs/02-providers.md | 6 +- internal/http/provider_models.go | 12 +- internal/http/provider_models_catalog.go | 33 ++++- internal/http/provider_models_test.go | 94 ++++++++++++++ internal/http/providers.go | 30 ++++- internal/http/providers_test.go | 115 +++++++++++++++++ internal/providers/openai_config.go | 12 +- internal/store/provider_store.go | 19 ++- 10 files changed, 480 insertions(+), 38 deletions(-) create mode 100644 cmd/gateway_providers_defaults_test.go diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 2d02bc59..46bc8f33 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -76,8 +76,11 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe } if cfg.Providers.MiniMax.APIKey != "" { - registry.Register(providers.NewOpenAIProvider("minimax", cfg.Providers.MiniMax.APIKey, "https://api.minimax.io/v1", "MiniMax-M2.5"). - WithChatPath("/text/chatcompletion_v2")) + base := cfg.Providers.MiniMax.APIBase + if base == "" { + base = store.MiniMaxDefaultAPIBase + } + registry.Register(providers.NewOpenAIProvider("minimax", cfg.Providers.MiniMax.APIKey, base, store.MiniMaxDefaultModel)) slog.Info("registered provider", "name", "minimax") } @@ -109,18 +112,18 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe if cfg.Providers.Zai.APIKey != "" { base := cfg.Providers.Zai.APIBase if base == "" { - base = "https://api.z.ai/api/paas/v4" + base = store.ZaiDefaultAPIBase } - registry.Register(providers.NewOpenAIProvider("zai", cfg.Providers.Zai.APIKey, base, "glm-5")) + registry.Register(providers.NewOpenAIProvider("zai", cfg.Providers.Zai.APIKey, base, store.ZaiDefaultModel)) slog.Info("registered provider", "name", "zai") } if cfg.Providers.ZaiCoding.APIKey != "" { base := cfg.Providers.ZaiCoding.APIBase if base == "" { - base = "https://api.z.ai/api/coding/paas/v4" + base = store.ZaiCodingDefaultAPIBase } - registry.Register(providers.NewOpenAIProvider("zai-coding", cfg.Providers.ZaiCoding.APIKey, base, "glm-5")) + registry.Register(providers.NewOpenAIProvider("zai-coding", cfg.Providers.ZaiCoding.APIKey, base, store.ZaiDefaultModel)) slog.Info("registered provider", "name", "zai-coding") } @@ -362,15 +365,15 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi case store.ProviderZai: base := p.APIBase if base == "" { - base = "https://api.z.ai/api/paas/v4" + base = store.ZaiDefaultAPIBase } - registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel)) case store.ProviderZaiCoding: base := p.APIBase if base == "" { - base = "https://api.z.ai/api/coding/paas/v4" + base = store.ZaiCodingDefaultAPIBase } - registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel)) case store.ProviderOllamaCloud: base := p.APIBase if base == "" { @@ -413,11 +416,9 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi }) registry.RegisterForTenant(p.TenantID, prov) default: - prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") + base, model := openAIProviderDefaults(p.ProviderType, p.APIBase) + prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model) prov.WithProviderType(p.ProviderType) - if p.ProviderType == store.ProviderMiniMax { - prov.WithChatPath("/text/chatcompletion_v2") - } if p.ProviderType == store.ProviderOpenRouter { prov.WithSiteInfo("https://goclaw.sh", "GoClaw") } @@ -427,6 +428,18 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi } } +func openAIProviderDefaults(providerType, apiBase string) (string, string) { + switch providerType { + case store.ProviderMiniMax: + if apiBase == "" { + apiBase = store.MiniMaxDefaultAPIBase + } + return apiBase, store.MiniMaxDefaultModel + default: + return apiBase, "" + } +} + func registerClaudeCLIFromConfig(registry *providers.Registry, cfg *config.Config) { if cfg == nil || cfg.Providers.ClaudeCLI.CLIPath == "" { return diff --git a/cmd/gateway_providers_defaults_test.go b/cmd/gateway_providers_defaults_test.go new file mode 100644 index 00000000..3f9b3474 --- /dev/null +++ b/cmd/gateway_providers_defaults_test.go @@ -0,0 +1,156 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestRegisterProvidersUsesCurrentMiniMaxAndZaiDefaults(t *testing.T) { + cfg := &config.Config{} + cfg.Providers.MiniMax.APIKey = "minimax-token" + cfg.Providers.Zai.APIKey = "zai-token" + cfg.Providers.ZaiCoding.APIKey = "zai-coding-token" + + registry := providers.NewRegistry(nil) + registerProviders(registry, cfg, providers.NewInMemoryRegistry()) + + assertProviderDefault(t, registry, providers.MasterTenantID, "minimax", "MiniMax-M3", "https://api.minimax.io/v1") + assertProviderDefault(t, registry, providers.MasterTenantID, "zai", "glm-5.2", "https://api.z.ai/api/paas/v4") + assertProviderDefault(t, registry, providers.MasterTenantID, "zai-coding", "glm-5.2", "https://api.z.ai/api/coding/paas/v4") +} + +func TestRegisterProvidersMiniMaxUsesOpenAIChatCompletionsPath(t *testing.T) { + var capturedPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]string{"content": "ok"}, + "finish_reason": "stop", + }, + }, + }) + })) + t.Cleanup(upstream.Close) + + cfg := &config.Config{} + cfg.Providers.MiniMax.APIKey = "minimax-token" + cfg.Providers.MiniMax.APIBase = upstream.URL + + registry := providers.NewRegistry(nil) + registerProviders(registry, cfg, providers.NewInMemoryRegistry()) + + runtimeProvider, err := registry.GetForTenant(providers.MasterTenantID, "minimax") + if err != nil { + t.Fatalf("GetForTenant() error = %v", err) + } + _, err = runtimeProvider.Chat(context.Background(), providers.ChatRequest{ + Messages: []providers.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if capturedPath != "/chat/completions" { + t.Fatalf("captured path = %q, want /chat/completions", capturedPath) + } +} + +func TestRegisterProvidersFromDBUsesCurrentMiniMaxAndZaiDefaults(t *testing.T) { + tenantID := uuid.New() + providerStore := gatewayProvidersStoreStub{ + providers: []store.LLMProviderData{ + { + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "db-minimax", + ProviderType: store.ProviderMiniMax, + APIKey: "minimax-token", + Enabled: true, + }, + { + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "db-zai", + ProviderType: store.ProviderZai, + APIKey: "zai-token", + Enabled: true, + }, + { + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "db-zai-coding", + ProviderType: store.ProviderZaiCoding, + APIKey: "zai-coding-token", + Enabled: true, + }, + }, + } + + registry := providers.NewRegistry(nil) + registerProvidersFromDB(registry, providerStore, nil, "", "", nil, &config.Config{}, providers.NewInMemoryRegistry()) + + assertProviderDefault(t, registry, tenantID, "db-minimax", "MiniMax-M3", "https://api.minimax.io/v1") + assertProviderDefault(t, registry, tenantID, "db-zai", "glm-5.2", "https://api.z.ai/api/paas/v4") + assertProviderDefault(t, registry, tenantID, "db-zai-coding", "glm-5.2", "https://api.z.ai/api/coding/paas/v4") +} + +func assertProviderDefault(t *testing.T, registry *providers.Registry, tenantID uuid.UUID, name, wantModel, wantBase string) { + t.Helper() + runtimeProvider, err := registry.GetForTenant(tenantID, name) + if err != nil { + t.Fatalf("GetForTenant(%q) error = %v", name, err) + } + if got := runtimeProvider.DefaultModel(); got != wantModel { + t.Fatalf("%s DefaultModel() = %q, want %q", name, got, wantModel) + } + openai, ok := runtimeProvider.(*providers.OpenAIProvider) + if !ok { + t.Fatalf("%s runtime provider = %T, want *providers.OpenAIProvider", name, runtimeProvider) + } + if got := openai.APIBase(); got != wantBase { + t.Fatalf("%s APIBase() = %q, want %q", name, got, wantBase) + } +} + +type gatewayProvidersStoreStub struct { + providers []store.LLMProviderData +} + +func (s gatewayProvidersStoreStub) CreateProvider(context.Context, *store.LLMProviderData) error { + return errors.New("not implemented") +} + +func (s gatewayProvidersStoreStub) GetProvider(context.Context, uuid.UUID) (*store.LLMProviderData, error) { + return nil, errors.New("not implemented") +} + +func (s gatewayProvidersStoreStub) GetProviderByName(context.Context, string) (*store.LLMProviderData, error) { + return nil, errors.New("not implemented") +} + +func (s gatewayProvidersStoreStub) ListProviders(context.Context) ([]store.LLMProviderData, error) { + return nil, errors.New("not implemented") +} + +func (s gatewayProvidersStoreStub) ListAllProviders(context.Context) ([]store.LLMProviderData, error) { + return s.providers, nil +} + +func (s gatewayProvidersStoreStub) UpdateProvider(context.Context, uuid.UUID, map[string]any) error { + return errors.New("not implemented") +} + +func (s gatewayProvidersStoreStub) DeleteProvider(context.Context, uuid.UUID) error { + return errors.New("not implemented") +} diff --git a/docs/02-providers.md b/docs/02-providers.md index 1a1885a9..5bcdf536 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -101,13 +101,13 @@ Supported price units: input, output, cache read, cache write, reasoning, reques | gemini | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Skips empty content fields | | mistral | `https://api.mistral.ai/v1` | `mistral-large-latest` | | | xai | `https://api.x.ai/v1` | `grok-3-mini` | | -| minimax | `https://api.minimax.io/v1` | `MiniMax-M2.5` | Uses custom chat path | +| minimax | `https://api.minimax.io/v1` | `MiniMax-M3` | Uses OpenAI-compatible chat completions; MiniMax also exposes an Anthropic-compatible API, but GoClaw keeps the current OpenAI-compatible path | | cohere | `https://api.cohere.ai/compatibility/v1` | `command-a` | | | perplexity | `https://api.perplexity.ai` | `sonar-pro` | | | ollama | `http://localhost:11434/v1` | `llama3.3` | Local/configurable | | bailian | `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.5-plus` | Alibaba Coding API | -| zai | `https://api.z.ai/api/paas/v4` | `glm-5` | | -| zai-coding | `https://api.z.ai/api/coding/paas/v4` | `glm-5` | | +| zai | `https://api.z.ai/api/paas/v4` | `glm-5.2` | 1M context, 128K max output | +| zai-coding | `https://api.z.ai/api/coding/paas/v4` | `glm-5.2` | 1M context, 128K max output | | byteplus | `https://ark.ap-southeast.bytepluses.com/api/v3` | `seed-2-0-lite-260228` | Seed 2.0 models | | byteplus_coding | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | `seed-2-0-lite-260228` | Seed 2.0 Coding Plan | diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 2ab10072..927f6eb0 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -97,16 +97,18 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht var models []ModelInfo switch p.ProviderType { - case "anthropic_native": + case store.ProviderAnthropicNative: models, err = fetchAnthropicModels(ctx, p.APIKey, h.resolveAPIBase(p)) - case "gemini_native": + case store.ProviderGeminiNative: models, err = fetchGeminiModels(ctx, p.APIKey) - case "bailian": + case store.ProviderBailian: models = bailianModels() - case "dashscope": + case store.ProviderDashScope: models = dashScopeModels() - case "minimax_native": + case store.ProviderMiniMax: models = minimaxModels() + case store.ProviderZai, store.ProviderZaiCoding: + models = zaiModels() default: // All other types use OpenAI-compatible /models endpoint apiBase := openAIModelsAPIBase(p.ProviderType, h.resolveAPIBase(p)) diff --git a/internal/http/provider_models_catalog.go b/internal/http/provider_models_catalog.go index e62c103d..b85863f8 100644 --- a/internal/http/provider_models_catalog.go +++ b/internal/http/provider_models_catalog.go @@ -1,6 +1,9 @@ package http -import "github.com/nextlevelbuilder/goclaw/internal/providers" +import ( + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) // bailianModels returns a hardcoded list of models available on the // Bailian Coding platform (coding-intl.dashscope.aliyuncs.com). @@ -26,10 +29,16 @@ func bailianModels() []ModelInfo { func minimaxModels() []ModelInfo { return []ModelInfo{ // Chat / text + {ID: store.MiniMaxDefaultModel, Name: "MiniMax M3"}, {ID: "MiniMax-Text-01", Name: "MiniMax Text 01"}, {ID: "MiniMax-M1", Name: "MiniMax M1"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, + {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 Highspeed"}, {ID: "MiniMax-M2.5", Name: "MiniMax M2.5"}, + {ID: "MiniMax-M2.5-highspeed", Name: "MiniMax M2.5 Highspeed"}, + {ID: "MiniMax-M2.1", Name: "MiniMax M2.1"}, + {ID: "MiniMax-M2.1-highspeed", Name: "MiniMax M2.1 Highspeed"}, + {ID: "MiniMax-M2", Name: "MiniMax M2"}, // Image generation {ID: "image-01", Name: "Image 01"}, // Video generation @@ -45,6 +54,28 @@ func minimaxModels() []ModelInfo { } } +// zaiModels returns a hardcoded list of Z.AI GLM models. +// Z.AI supports OpenAI-compatible chat completions, but /models availability is +// not required for GoClaw's model picker. +func zaiModels() []ModelInfo { + return []ModelInfo{ + {ID: store.ZaiDefaultModel, Name: "GLM 5.2"}, + {ID: "glm-5.1", Name: "GLM 5.1"}, + {ID: "glm-5-turbo", Name: "GLM 5 Turbo"}, + {ID: "glm-5", Name: "GLM 5"}, + {ID: "glm-4.7", Name: "GLM 4.7"}, + {ID: "glm-4.7-flash", Name: "GLM 4.7 Flash"}, + {ID: "glm-4.7-flashx", Name: "GLM 4.7 FlashX"}, + {ID: "glm-4.6", Name: "GLM 4.6"}, + {ID: "glm-4.5", Name: "GLM 4.5"}, + {ID: "glm-4.5-air", Name: "GLM 4.5 Air"}, + {ID: "glm-4.5-x", Name: "GLM 4.5 X"}, + {ID: "glm-4.5-airx", Name: "GLM 4.5 AirX"}, + {ID: "glm-4.5-flash", Name: "GLM 4.5 Flash"}, + {ID: "glm-4-32b-0414-128k", Name: "GLM 4 32B 0414 128K"}, + } +} + // dashScopeModels returns a hardcoded list of DashScope (Qwen) models. // DashScope does not expose a standard /v1/models endpoint. func dashScopeModels() []ModelInfo { diff --git a/internal/http/provider_models_test.go b/internal/http/provider_models_test.go index 2c42b242..af53216a 100644 --- a/internal/http/provider_models_test.go +++ b/internal/http/provider_models_test.go @@ -52,6 +52,22 @@ func ollamaModelsRequest(t *testing.T, mux *http.ServeMux, providerID uuid.UUID, return result, w.Code } +func providerModelsRequest(t *testing.T, mux *http.ServeMux, providerID uuid.UUID, token string) ProviderModelsResponse { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+providerID.String()+"/models", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + var result ProviderModelsResponse + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Decode() error = %v", err) + } + return result +} + func TestProvidersHandlerListProviderModelsChatGPTOAuthIncludesReasoningMetadata(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() @@ -164,6 +180,84 @@ func TestProvidersHandlerListProviderModelsBailianIncludesQwen37Plus(t *testing. t.Fatalf("qwen3.7-plus not found in Bailian model list: %#v", result.Models) } +func TestProvidersHandlerListProviderModelsMiniMaxIncludesM3AndLegacyModels(t *testing.T) { + token := setupProvidersAdminToken(t) + providerStore := newMockProviderStore() + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: "minimax", + ProviderType: store.ProviderMiniMax, + APIKey: "token", + Enabled: true, + } + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result := providerModelsRequest(t, mux, provider.ID, token) + assertModelIDsInOrder(t, result.Models, []string{ + "MiniMax-M3", + "MiniMax-M2.7", + "MiniMax-M2.5", + "MiniMax-M2.1", + "MiniMax-M2", + }) +} + +func TestProvidersHandlerListProviderModelsZaiUsesLocalCatalog(t *testing.T) { + token := setupProvidersAdminToken(t) + + for _, providerType := range []string{store.ProviderZai, store.ProviderZaiCoding} { + t.Run(providerType, func(t *testing.T) { + providerStore := newMockProviderStore() + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: providerType, + ProviderType: providerType, + APIKey: "token", + Enabled: true, + } + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result := providerModelsRequest(t, mux, provider.ID, token) + assertModelIDsInOrder(t, result.Models, []string{ + "glm-5.2", + "glm-5.1", + "glm-5", + "glm-4.7", + "glm-4.5", + }) + }) + } +} + +func assertModelIDsInOrder(t *testing.T, models []ModelInfo, want []string) { + t.Helper() + position := 0 + for _, model := range models { + if position < len(want) && model.ID == want[position] { + position++ + } + } + if position != len(want) { + got := make([]string, 0, len(models)) + for _, model := range models { + got = append(got, model.ID) + } + t.Fatalf("model IDs = %#v, want sequence %#v", got, want) + } +} + func TestProvidersHandlerListProviderModelsOpenAICompatAnnotatesKnownModels(t *testing.T) { token := setupProvidersAdminToken(t) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/providers.go b/internal/http/providers.go index c83f9e77..060c5591 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -309,6 +309,18 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRu } h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"). WithProviderType(p.ProviderType)) + case store.ProviderZai: + base := apiBase + if base == "" { + base = store.ZaiDefaultAPIBase + } + h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel)) + case store.ProviderZaiCoding: + base := apiBase + if base == "" { + base = store.ZaiCodingDefaultAPIBase + } + h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel)) case store.ProviderNovita: base := apiBase if base == "" { @@ -328,15 +340,25 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRu }) h.providerReg.RegisterForTenant(p.TenantID, prov) default: - prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "") - if p.ProviderType == store.ProviderMiniMax { - prov.WithChatPath("/text/chatcompletion_v2") - } + base, model := openAIProviderDefaults(p.ProviderType, apiBase) + prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model) h.providerReg.RegisterForTenant(p.TenantID, prov) } return providerRuntimeRegistered } +func openAIProviderDefaults(providerType, apiBase string) (string, string) { + switch providerType { + case store.ProviderMiniMax: + if apiBase == "" { + apiBase = store.MiniMaxDefaultAPIBase + } + return apiBase, store.MiniMaxDefaultModel + default: + return apiBase, "" + } +} + // normalizeOllamaAPIBase ensures Ollama and OllamaCloud api_base values include the // /v1 suffix required for OpenAI-compatible endpoints. Normalizing at write time means // resolveAPIBase() always returns a ready-to-use base URL. diff --git a/internal/http/providers_test.go b/internal/http/providers_test.go index d28d62b4..c9c58989 100644 --- a/internal/http/providers_test.go +++ b/internal/http/providers_test.go @@ -95,6 +95,121 @@ func TestProvidersHandlerRegisterInMemoryUsesDBNameForAnthropic(t *testing.T) { } } +func TestProvidersHandlerRegisterInMemoryMiniMaxUsesM3Default(t *testing.T) { + providerReg := providers.NewRegistry(nil) + handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") + + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: uuid.New(), + Name: "minimax", + ProviderType: store.ProviderMiniMax, + APIKey: "token", + Enabled: true, + } + + handler.registerInMemory(provider) + + runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name) + if err != nil { + t.Fatalf("GetForTenant() error = %v", err) + } + if got := runtimeProvider.DefaultModel(); got != "MiniMax-M3" { + t.Fatalf("DefaultModel() = %q, want MiniMax-M3", got) + } + openai, ok := runtimeProvider.(*providers.OpenAIProvider) + if !ok { + t.Fatalf("runtime provider = %T, want *providers.OpenAIProvider", runtimeProvider) + } + if got := openai.APIBase(); got != "https://api.minimax.io/v1" { + t.Fatalf("APIBase() = %q, want MiniMax default base", got) + } +} + +func TestProvidersHandlerRegisterInMemoryMiniMaxUsesOpenAIChatCompletionsPath(t *testing.T) { + var capturedPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]string{"content": "ok"}, + "finish_reason": "stop", + }, + }, + }) + })) + t.Cleanup(upstream.Close) + + providerReg := providers.NewRegistry(nil) + handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: uuid.New(), + Name: "minimax", + ProviderType: store.ProviderMiniMax, + APIBase: upstream.URL, + APIKey: "token", + Enabled: true, + } + + handler.registerInMemory(provider) + + runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name) + if err != nil { + t.Fatalf("GetForTenant() error = %v", err) + } + _, err = runtimeProvider.Chat(context.Background(), providers.ChatRequest{ + Messages: []providers.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if capturedPath != "/chat/completions" { + t.Fatalf("captured path = %q, want /chat/completions", capturedPath) + } +} + +func TestProvidersHandlerRegisterInMemoryZaiUsesGLM52Default(t *testing.T) { + for _, tt := range []struct { + providerType string + wantBase string + }{ + {providerType: store.ProviderZai, wantBase: "https://api.z.ai/api/paas/v4"}, + {providerType: store.ProviderZaiCoding, wantBase: "https://api.z.ai/api/coding/paas/v4"}, + } { + t.Run(tt.providerType, func(t *testing.T) { + providerReg := providers.NewRegistry(nil) + handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: uuid.New(), + Name: tt.providerType, + ProviderType: tt.providerType, + APIKey: "token", + Enabled: true, + } + + handler.registerInMemory(provider) + + runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name) + if err != nil { + t.Fatalf("GetForTenant() error = %v", err) + } + if got := runtimeProvider.DefaultModel(); got != "glm-5.2" { + t.Fatalf("DefaultModel() = %q, want glm-5.2", got) + } + openai, ok := runtimeProvider.(*providers.OpenAIProvider) + if !ok { + t.Fatalf("runtime provider = %T, want *providers.OpenAIProvider", runtimeProvider) + } + if got := openai.APIBase(); got != tt.wantBase { + t.Fatalf("APIBase() = %q, want %q", got, tt.wantBase) + } + }) + } +} + // TestProvidersHandlerRegisterInMemoryUsesDBNameForClaudeCLI mirrors the Anthropic guard for Claude CLI. // Custom-named CLI providers must be registered under their DB name to be locatable via verify. func TestProvidersHandlerRegisterInMemoryUsesDBNameForClaudeCLI(t *testing.T) { diff --git a/internal/providers/openai_config.go b/internal/providers/openai_config.go index e6846b97..46418e6f 100644 --- a/internal/providers/openai_config.go +++ b/internal/providers/openai_config.go @@ -14,15 +14,15 @@ type OpenAIProvider struct { chatPath string // defaults to "/chat/completions" authPrefix string // auth header prefix, defaults to "Bearer " if empty defaultModel string - providerType string // DB provider_type (e.g. "gemini_native", "openai", "minimax_native") - siteURL string // optional site URL for provider identification (e.g. OpenRouter HTTP-Referer) - siteTitle string // optional site title for provider identification (e.g. OpenRouter X-Title) + providerType string // DB provider_type (e.g. "gemini_native", "openai", "minimax_native") + siteURL string // optional site URL for provider identification (e.g. OpenRouter HTTP-Referer) + siteTitle string // optional site title for provider identification (e.g. OpenRouter X-Title) extraHeaders map[string]string // static headers set on every outgoing request (e.g. fixed User-Agent for kimi_coding) client *http.Client retryConfig RetryConfig middlewares RequestMiddleware // composed middleware chain (nil = no-op) - registry ModelRegistry // model resolution registry (nil = skip) - noAuthHeader bool // when true, doRequest() skips setting Authorization (e.g. Vertex OAuth transport injects its own) + registry ModelRegistry // model resolution registry (nil = skip) + noAuthHeader bool // when true, doRequest() skips setting Authorization (e.g. Vertex OAuth transport injects its own) } func NewOpenAIProvider(name, apiKey, apiBase, defaultModel string) *OpenAIProvider { @@ -43,7 +43,7 @@ func NewOpenAIProvider(name, apiKey, apiBase, defaultModel string) *OpenAIProvid } } -// WithChatPath returns a copy with a custom chat completions path (e.g. "/text/chatcompletion_v2" for MiniMax native API). +// WithChatPath returns a copy with a custom chat completions path. func (p *OpenAIProvider) WithChatPath(path string) *OpenAIProvider { p.chatPath = path return p diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index 1a0d08a5..7cb79f0e 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -27,15 +27,24 @@ const ( ProviderYesScale = "yescale" ProviderZai = "zai" ProviderZaiCoding = "zai_coding" - ProviderOllama = "ollama" // local or self-hosted Ollama (no API key) - ProviderOllamaCloud = "ollama_cloud" // Ollama Cloud (Bearer token required) - ProviderACP = "acp" // ACP (Agent Client Protocol) agent subprocess + ProviderOllama = "ollama" // local or self-hosted Ollama (no API key) + ProviderOllamaCloud = "ollama_cloud" // Ollama Cloud (Bearer token required) + ProviderACP = "acp" // ACP (Agent Client Protocol) agent subprocess ProviderNovita = "novita" // Novita AI (OpenAI-compatible endpoint) ProviderBytePlus = "byteplus" // BytePlus ModelArk (Seed 2.0 models) ProviderBytePlusCoding = "byteplus_coding" // BytePlus ModelArk Coding Plan ProviderVertex = "vertex" // Google Cloud Vertex AI (OAuth2 service account + ADC) ProviderKimiCoding = "kimi_coding" // Moonshot Kimi Coding (OpenAI-compat, requires fixed User-Agent) + // MiniMax defaults. + MiniMaxDefaultAPIBase = "https://api.minimax.io/v1" + MiniMaxDefaultModel = "MiniMax-M3" + + // Z.AI defaults. + ZaiDefaultAPIBase = "https://api.z.ai/api/paas/v4" + ZaiCodingDefaultAPIBase = "https://api.z.ai/api/coding/paas/v4" + ZaiDefaultModel = "glm-5.2" + // Novita AI defaults. NovitaDefaultAPIBase = "https://api.novita.ai/openai" NovitaDefaultModel = "moonshotai/kimi-k2.5" @@ -48,8 +57,8 @@ const ( // Kimi Coding defaults. The upstream requires a fixed User-Agent on every // request — handled by the runtime in cmd/gateway_providers.go via // OpenAIProvider.WithExtraHeaders. - KimiCodingDefaultAPIBase = "https://api.kimi.com/coding/v1" - KimiCodingDefaultModel = "kimi-k2-turbo-preview" + KimiCodingDefaultAPIBase = "https://api.kimi.com/coding/v1" + KimiCodingDefaultModel = "kimi-k2-turbo-preview" KimiCodingRequiredUserAgent = "claude-code/0.1.0" )