diff --git a/config.example.jsonc b/config.example.jsonc index c1a67899..b75b2d8a 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -33,6 +33,17 @@ "headers": { "X-Custom-Client": "my-client" } } }, + "atlascloud": { + // Atlas Cloud 使用 OpenAI-compatible Chat Completions API。 + // 设为默认时,把顶层 provider 改为 "atlascloud",model 改为下列任一模型。 + "type": "openai", + "api_key": "atl-xxx", + "base_url": "https://api.atlascloud.ai/v1", + "models": [ + { "name": "qwen/qwen3.5-flash", "context_window": 1000000 }, + { "name": "deepseek-ai/deepseek-v4-pro", "context_window": 1048576 } + ] + }, "anthropic": { "api_key": "sk-ant-xxx", "models": [{ "name": "claude-sonnet-4" }, { "name": "claude-opus-4" }] diff --git a/internal/bootstrap/config.example.jsonc b/internal/bootstrap/config.example.jsonc index c1a67899..b75b2d8a 100644 --- a/internal/bootstrap/config.example.jsonc +++ b/internal/bootstrap/config.example.jsonc @@ -33,6 +33,17 @@ "headers": { "X-Custom-Client": "my-client" } } }, + "atlascloud": { + // Atlas Cloud 使用 OpenAI-compatible Chat Completions API。 + // 设为默认时,把顶层 provider 改为 "atlascloud",model 改为下列任一模型。 + "type": "openai", + "api_key": "atl-xxx", + "base_url": "https://api.atlascloud.ai/v1", + "models": [ + { "name": "qwen/qwen3.5-flash", "context_window": 1000000 }, + { "name": "deepseek-ai/deepseek-v4-pro", "context_window": 1048576 } + ] + }, "anthropic": { "api_key": "sk-ant-xxx", "models": [{ "name": "claude-sonnet-4" }, { "name": "claude-opus-4" }] diff --git a/internal/bootstrap/config_test.go b/internal/bootstrap/config_test.go index e836bf90..682172c3 100644 --- a/internal/bootstrap/config_test.go +++ b/internal/bootstrap/config_test.go @@ -132,3 +132,51 @@ func TestValidateBaseRejectsBadStreamIdleTimeout(t *testing.T) { t.Fatalf("非法 stream_idle_timeout 应拒绝并包装 ErrConfig,得到: %v", err) } } + +func TestProviderPresetsIncludeAtlasCloudOpenAICompatibleDefaults(t *testing.T) { + var atlas ProviderPreset + for _, preset := range ProviderPresets() { + if preset.Name == "atlascloud" { + atlas = preset + break + } + } + if atlas.Name == "" { + t.Fatal("ProviderPresets should include atlascloud") + } + if atlas.Label != "Atlas Cloud" || atlas.Type != "openai" { + t.Fatalf("atlascloud preset label/type = %q/%q", atlas.Label, atlas.Type) + } + if atlas.BaseURL != "https://api.atlascloud.ai/v1" { + t.Fatalf("atlascloud base url = %q", atlas.BaseURL) + } + if atlas.DefaultModel != "qwen/qwen3.5-flash" { + t.Fatalf("atlascloud default model = %q", atlas.DefaultModel) + } + wantModels := map[string]int{ + "qwen/qwen3.5-flash": 1000000, + "deepseek-ai/deepseek-v4-pro": 1048576, + } + if len(atlas.Models) != len(wantModels) { + t.Fatalf("atlascloud models = %#v", atlas.Models) + } + for _, model := range atlas.Models { + if want, ok := wantModels[model.Name]; !ok || model.ContextWindow != want { + t.Fatalf("unexpected atlascloud model entry: %#v", model) + } + } +} + +func TestPresetModelsWithSelectionPreservesCatalogAndAddsCustomSelection(t *testing.T) { + preset := []ModelConfig{{Name: "qwen/qwen3.5-flash", ContextWindow: 1000000}} + got := presetModelsWithSelection(preset, "custom-model") + if len(got) != 2 { + t.Fatalf("models = %#v", got) + } + if got[0].Name != "qwen/qwen3.5-flash" || got[0].ContextWindow != 1000000 { + t.Fatalf("preset model was not preserved: %#v", got[0]) + } + if got[1].Name != "custom-model" || got[1].ContextWindow != 0 { + t.Fatalf("custom selected model not appended: %#v", got[1]) + } +} diff --git a/internal/bootstrap/setup.go b/internal/bootstrap/setup.go index 1d601618..8c6a9af3 100644 --- a/internal/bootstrap/setup.go +++ b/internal/bootstrap/setup.go @@ -35,7 +35,10 @@ func NeedsSetup() bool { type setupProvider struct { name string label string + providerType string baseURL string // 预填的 base_url + defaultModel string + models []ModelConfig needType bool // 自定义代理需要额外问 type 和 base_url apiKeyOptional bool // true 表示 API Key 允许留空 } @@ -44,13 +47,27 @@ type setupProvider struct { type ProviderPreset struct { Name string Label string + Type string BaseURL string + DefaultModel string + Models []ModelConfig NeedType bool APIKeyOptional bool } var setupProviders = []setupProvider{ {name: "openrouter", label: "OpenRouter", baseURL: "https://openrouter.ai/api/v1"}, + { + name: "atlascloud", + label: "Atlas Cloud", + providerType: "openai", + baseURL: "https://api.atlascloud.ai/v1", + defaultModel: "qwen/qwen3.5-flash", + models: []ModelConfig{ + {Name: "qwen/qwen3.5-flash", ContextWindow: 1000000}, + {Name: "deepseek-ai/deepseek-v4-pro", ContextWindow: 1048576}, + }, + }, {name: "anthropic", label: "Anthropic"}, {name: "gemini", label: "Gemini"}, {name: "openai", label: "OpenAI"}, @@ -68,8 +85,14 @@ func ProviderPresets() []ProviderPreset { out := make([]ProviderPreset, 0, len(setupProviders)) for _, preset := range setupProviders { out = append(out, ProviderPreset{ - Name: preset.name, Label: preset.label, BaseURL: preset.baseURL, - NeedType: preset.needType, APIKeyOptional: preset.apiKeyOptional, + Name: preset.name, + Label: preset.label, + Type: preset.providerType, + BaseURL: preset.baseURL, + DefaultModel: preset.defaultModel, + Models: append([]ModelConfig(nil), preset.models...), + NeedType: preset.needType, + APIKeyOptional: preset.apiKeyOptional, }) } return out @@ -105,6 +128,8 @@ func RunSetup() (Config, error) { return Config{}, err } pc.Type = providerType + } else if sp.providerType != "" { + pc.Type = sp.providerType } // Step 2: 输入 API Key @@ -142,12 +167,16 @@ func RunSetup() (Config, error) { } // Step 4: 模型名(必填) - modelName, err := runTextInput("[4/4] 模型名称", "例如:gpt-4o / claude-sonnet-4 / gemini-2.5-pro") + modelHint := "例如:gpt-4o / claude-sonnet-4 / gemini-2.5-pro" + if sp.defaultModel != "" { + modelHint = sp.defaultModel + } + modelName, err := runTextInputWithDefault("[4/4] 模型名称", modelHint, sp.defaultModel) if err != nil { return Config{}, err } printStepDone("Model", modelName) - pc.Models = []ModelConfig{{Name: modelName}} + pc.Models = presetModelsWithSelection(sp.models, modelName) cfg := Config{ Provider: providerName, @@ -182,6 +211,25 @@ func RunSetup() (Config, error) { return cfg, nil } +func presetModelsWithSelection(preset []ModelConfig, selected string) []ModelConfig { + selected = strings.TrimSpace(selected) + out := make([]ModelConfig, 0, len(preset)+1) + seen := make(map[string]bool, len(preset)+1) + for _, model := range preset { + name := strings.TrimSpace(model.Name) + if name == "" || seen[name] { + continue + } + model.Name = name + out = append(out, model) + seen[name] = true + } + if selected != "" && !seen[selected] { + out = append(out, ModelConfig{Name: selected}) + } + return out +} + func saveExampleConfig() { dir, err := configDir() if err != nil { diff --git a/internal/entry/tui/command_config.go b/internal/entry/tui/command_config.go index 828a6d7a..c937b73d 100644 --- a/internal/entry/tui/command_config.go +++ b/internal/entry/tui/command_config.go @@ -142,8 +142,9 @@ func (s *modelConfigState) applyProviderChoice(choice configProviderChoice) { return } s.provider = choice.preset.Name - s.providerType = "" // 内置 provider 协议由名称隐含 + s.providerType = choice.preset.Type // 为空表示内置 provider 协议由名称隐含 s.baseURL = choice.preset.BaseURL + s.models = append([]bootstrap.ModelConfig(nil), choice.preset.Models...) s.apiKeyOptional = choice.preset.APIKeyOptional s.step = configStepHub } diff --git a/internal/entry/tui/command_config_test.go b/internal/entry/tui/command_config_test.go index d0903c7d..521f5289 100644 --- a/internal/entry/tui/command_config_test.go +++ b/internal/entry/tui/command_config_test.go @@ -218,3 +218,33 @@ func TestProviderMenuIsTwoLevel(t *testing.T) { } } } + +func TestAtlasCloudPresetUsesOpenAICompatibleDefaults(t *testing.T) { + var preset *bootstrap.ProviderPreset + for _, value := range bootstrap.ProviderPresets() { + if value.Name == "atlascloud" { + copyValue := value + preset = ©Value + break + } + } + if preset == nil { + t.Fatal("atlascloud preset not found") + } + + state := &modelConfigState{} + state.applyProviderChoice(configProviderChoice{label: preset.Label, preset: preset}) + if state.provider != "atlascloud" || state.providerType != "openai" { + t.Fatalf("provider/type = %q/%q", state.provider, state.providerType) + } + if state.baseURL != "https://api.atlascloud.ai/v1" { + t.Fatalf("baseURL = %q", state.baseURL) + } + if len(state.models) != 2 || state.models[0].Name != "qwen/qwen3.5-flash" { + t.Fatalf("atlascloud models = %#v", state.models) + } + ids := hubFieldIDs(state.hubFields()) + if !slices.Contains(ids, "protocol") || !slices.Contains(ids, "api") { + t.Fatalf("atlascloud hub should expose OpenAI protocol and endpoint fields, got %v", ids) + } +}