Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
Expand Down Expand Up @@ -244,6 +245,12 @@ To configure GMI Cloud directly, run:
zero providers setup gmicloud --set-active
```

To configure Concentrate directly, run:

```bash
zero providers setup concentrate --set-active
```

MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:

Expand Down
7 changes: 7 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
```

要直接配置美团 LongCat(LongCat-2.0),运行:
Expand All @@ -200,6 +201,12 @@ zero providers setup longcat --set-active
zero providers setup gmicloud --set-active
```

要直接配置 Concentrate,运行:

```bash
zero providers setup concentrate --set-active
```

对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
Expand Down
1 change: 1 addition & 0 deletions internal/cli/provider_catalog_onboarding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
{id: "gmicloud", name: "GMI Cloud", transport: "openai-compatible", defaultModel: "Qwen/Qwen3.8-Max"},
{id: "concentrate", name: "Concentrate", transport: "openai-compatible", defaultModel: "deepseek-v4-flash-0731"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
Expand Down
8 changes: 8 additions & 0 deletions internal/providercatalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ var descriptors = []Descriptor{
// provider id so the remote catalog resolves without a mapping; GMI's own
// docs name the key GMI_API_KEY, models.dev names it GMICLOUD_API_KEY.
openAICompat("gmicloud", "GMI Cloud", "https://api.gmi-serving.com/v1", "Qwen/Qwen3.8-Max", []string{"GMI_API_KEY", "GMICLOUD_API_KEY"}, "gmi cloud", "gmi"),
// Concentrate AI — OpenAI-compatible gateway to models from many labs.
// models.dev has no Concentrate provider, so the id and key name match
// OpenClaude's Concentrate integration. The default is the dated id
// deepseek-v4-flash-0731: Concentrate's /v1/models keeps the rolling name
// deepseek-v4-flash only as an alias, and onboarding matches the default
// against live ids. "concentrate ai" also covers "concentrate.ai" and
// "concentrate-ai" once normalized.
openAICompat("concentrate", "Concentrate", "https://api.concentrate.ai/v1", "deepseek-v4-flash-0731", []string{"CONCENTRATE_API_KEY"}, "concentrate ai"),
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
Expand Down
32 changes: 31 additions & 1 deletion internal/providercatalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var expectedCatalogIDs = []string{
"moonshot",
"atlascloud",
"gmicloud",
"concentrate",
"longcat",
"nvidia-nim",
"minimax",
Expand Down Expand Up @@ -227,6 +228,31 @@ func TestGMICloudDescriptor(t *testing.T) {
}
}

func TestConcentrateDescriptor(t *testing.T) {
descriptor, err := Require("concentrate")
if err != nil {
t.Fatalf("Require(concentrate) error = %v", err)
}
if descriptor.Name != "Concentrate" {
t.Fatalf("Name = %q, want Concentrate", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.concentrate.ai/v1" {
t.Fatalf("DefaultBaseURL = %q, want Concentrate OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "deepseek-v4-flash-0731" {
t.Fatalf("DefaultModel = %q, want deepseek-v4-flash-0731", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !descriptor.RequiresAuth {
t.Fatal("RequiresAuth = false, want true")
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"CONCENTRATE_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want CONCENTRATE_API_KEY", descriptor.AuthEnvVars)
}
}

func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
Expand Down Expand Up @@ -350,6 +376,10 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"GMI Cloud": "gmicloud",
"gmi-cloud": "gmicloud",
"gmi": "gmicloud",
"Concentrate": "concentrate",
"concentrate ai": "concentrate",
"concentrate.ai": "concentrate",
"concentrate-ai": "concentrate",
"nvidia nim": "nvidia-nim",
"xiaomi mimo": "xiaomi-mimo",
"custom_openai_compatible": "custom-openai-compatible",
Expand Down Expand Up @@ -400,7 +430,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "gmicloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "gmicloud", "concentrate", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}

for transport, wantIDs := range cases {
Expand Down
14 changes: 14 additions & 0 deletions internal/providermodelcatalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type Model struct {

const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"

const concentrateModelSource = "https://api.concentrate.ai/v1/models"

// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
// per call, so the shared backing slice cannot be mutated by callers.
// Flat cost fields stay unset because MiniMax-M3 pricing is tiered and this
Expand Down Expand Up @@ -155,6 +157,18 @@ var curatedModels = map[string][]Model{
{ID: "deepseek-ai/DeepSeek-V4-Pro", Description: "reasoning model"},
{ID: "deepseek-ai/DeepSeek-V4-Flash", Description: "fast coding model"},
},
// Context windows, tool calling and thinking support come from
// concentrateModelSource (max_input_tokens, function_calling, thinking).
// Max output: 393,216 for both DeepSeek V4 models, 262,144 for Kimi K3 and
// GLM-5.3. The default is the dated id deepseek-v4-flash-0731, the model
// behind the deepseek-v4-flash alias (OpenClaude's Concentrate default): the
// live list carries ids only, so an alias would not survive discovery.
"concentrate": {
{ID: "deepseek-v4-flash-0731", Description: "catalog default", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
{ID: "deepseek-v4-pro", Description: "reasoning model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
{ID: "kimi-k3", Description: "coding model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
{ID: "glm-5.3", Description: "coding model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
},
"nvidia-nim": {
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "catalog default"},
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
Expand Down
27 changes: 27 additions & 0 deletions internal/providermodelcatalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"Qwen/Qwen3.8-Max", "Qwen/Qwen3.8-Flash", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-ai/deepseek-v4-pro"},
},
{
provider: "concentrate",
want: []string{"deepseek-v4-flash-0731", "deepseek-v4-pro", "kimi-k3", "glm-5.3"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-ai/DeepSeek-V4-Pro"},
},
{
provider: "mistral",
want: []string{"mistral-large-latest", "codestral-latest"},
Expand Down Expand Up @@ -183,6 +188,28 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
}
}

func TestConcentrateModelsExposeCatalogLimits(t *testing.T) {
descriptor, ok := providercatalog.Get("concentrate")
if !ok {
t.Fatal("provider concentrate missing from catalog")
}
models := Models(descriptor)
if got, want := modelIDs(models), []string{"deepseek-v4-flash-0731", "deepseek-v4-pro", "kimi-k3", "glm-5.3"}; !reflect.DeepEqual(got, want) {
t.Fatalf("Concentrate models = %#v, want %#v", got, want)
}
if models[0].Description != "catalog default" {
t.Fatalf("first Concentrate model description = %q, want catalog default", models[0].Description)
}
for _, model := range models {
if model.ContextWindow != 1_048_576 || !model.ToolCall || !model.Reasoning {
t.Fatalf("%s capabilities = %#v, want 1,048,576 context with tools and reasoning", model.ID, model)
}
if model.Source != concentrateModelSource {
t.Fatalf("%s source = %q, want %q", model.ID, model.Source, concentrateModelSource)
}
}
}

// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
// international provider; a divergence here is almost always a mistake.
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
Expand Down
52 changes: 52 additions & 0 deletions internal/providermodeldiscovery/discover_fallback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"

"github.com/Gitlawb/zero/internal/config"
Expand Down Expand Up @@ -38,3 +39,54 @@ func TestDiscoverCatalogFallsBackWhenLiveIDsMismatch(t *testing.T) {
t.Fatalf("models = %q, want curated catalog fallback (gpt-4.1) instead of an empty list", got)
}
}

// Concentrate's /v1/models lists dated snapshots as ids and keeps rolling names
// such as deepseek-v4-flash only in "aliases", which discovery does not read.
// Onboarding keeps the default selected only when it matches a live id, so the
// preset's default must be an id, or setup lands on whatever model sorts first.
func TestDiscoverCatalogConcentrateKeepsDefaultModelInLiveList(t *testing.T) {
var liveHits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api.json":
_, _ = w.Write([]byte(`{}`))
case "/v1/models":
liveHits.Add(1)
_, _ = w.Write([]byte(`{"object":"list","data":[
{"id":"claude-fable-5","slug":"claude-fable-5","max_input_tokens":1000000},
{"id":"deepseek-v4-flash-0731","slug":"deepseek-v4-flash-0731","aliases":["deepseek-v4-flash"],"max_input_tokens":1048576},
{"id":"deepseek-v4-pro","slug":"deepseek-v4-pro","max_input_tokens":1048576},
{"id":"kimi-k3","slug":"kimi-k3","max_input_tokens":1048576},
{"id":"glm-5.3","slug":"glm-5.3","max_input_tokens":1048576}
]}`))
default:
t.Fatalf("unexpected request path %q", r.URL.Path)
}
}))
defer server.Close()

provider, ok := providercatalog.Get("concentrate")
if !ok {
t.Fatal("provider concentrate missing from catalog")
}
provider.DefaultBaseURL = server.URL + "/v1"
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
CatalogID: "concentrate", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: server.URL + "/v1", APIKey: "sk-test",
}, Options{HTTPClient: server.Client(), ModelsDevURL: server.URL + "/api.json"})
if err != nil {
t.Fatalf("DiscoverCatalog returned error: %v", err)
}
if liveHits.Load() == 0 {
t.Fatal("DiscoverCatalog never requested /v1/models")
}
// claude-fable-5 is only in the live response, never in the curated list:
// finding it proves the result is the live list, not the curated fallback
// (which also contains the default and would pass on its own).
got := "," + strings.Join(modelIDs(models), ",") + ","
if !strings.Contains(got, ",claude-fable-5,") {
t.Fatalf("models = %q, want the live Concentrate list (with live-only claude-fable-5), not the curated fallback", strings.Trim(got, ","))
}
if !strings.Contains(got, ","+provider.DefaultModel+",") {
t.Fatalf("default model %q missing from discovered models %q", provider.DefaultModel, strings.Trim(got, ","))
}
}
Loading