From c6b3e17f72a591d4adf9f7126c9438e5517e4b27 Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:42:59 +0800 Subject: [PATCH 1/2] feat(config): support shared top-level providers --- cmd/cc-connect/main.go | 72 ++++++------ cmd/cc-connect/main_test.go | 170 +++++++++++++++++++++++++++ cmd/cc-connect/provider.go | 14 +-- config.example.toml | 52 +++++---- config/config.go | 221 ++++++++++++++++++++++++------------ config/config_test.go | 204 ++++++++++++++++++++++++++++----- docs/usage.md | 28 +++-- docs/usage.zh-CN.md | 28 +++-- 8 files changed, 602 insertions(+), 187 deletions(-) diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index 9cfbbfc9..3fb2670f 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -137,24 +137,9 @@ func main() { os.Exit(1) } - // Wire providers if the agent supports it - if ps, ok := agent.(core.ProviderSwitcher); ok && len(proj.Agent.Providers) > 0 { - providers := make([]core.ProviderConfig, len(proj.Agent.Providers)) - for i, p := range proj.Agent.Providers { - providers[i] = core.ProviderConfig{ - Name: p.Name, - APIKey: p.APIKey, - BaseURL: p.BaseURL, - Model: p.Model, - Models: convertProviderModels(p.Models), - Thinking: p.Thinking, - Env: p.Env, - } - } - ps.SetProviders(providers) - if active, _ := proj.Agent.Options["provider"].(string); active != "" { - ps.SetActiveProvider(active) - } + if _, err := configureAgentProviders(agent, cfg, proj.Name, proj.Agent.Options); err != nil { + slog.Error("failed to configure providers", "project", proj.Name, "error", err) + os.Exit(1) } var platforms []core.Platform @@ -1209,20 +1194,10 @@ func reloadConfig(configPath, projName string, engine *core.Engine) (*core.Confi engine.SetAttachmentSendEnabled(cfg.AttachmentSend != "off") // Reload providers - if ps, ok := engine.GetAgent().(core.ProviderSwitcher); ok { - providers := make([]core.ProviderConfig, len(proj.Agent.Providers)) - for i, p := range proj.Agent.Providers { - providers[i] = core.ProviderConfig{ - Name: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, - Model: p.Model, Models: convertProviderModels(p.Models), Thinking: p.Thinking, Env: p.Env, - } - } - ps.SetProviders(providers) - result.ProvidersUpdated = len(providers) - - if active, _ := proj.Agent.Options["provider"].(string); active != "" { - ps.SetActiveProvider(active) - } + if updated, err := configureAgentProviders(engine.GetAgent(), cfg, proj.Name, proj.Agent.Options); err != nil { + return nil, fmt.Errorf("reload providers: %w", err) + } else { + result.ProvidersUpdated = updated } // Reload custom commands @@ -1302,6 +1277,39 @@ func convertProviderModels(ms []config.ProviderModelConfig) []core.ModelOption { return opts } +func configureAgentProviders(agent core.Agent, cfg *config.Config, projectName string, options map[string]any) (int, error) { + ps, ok := agent.(core.ProviderSwitcher) + if !ok { + return 0, nil + } + + providerConfigs, err := config.GetEffectiveProjectProviders(cfg, projectName) + if err != nil { + return 0, err + } + + providers := make([]core.ProviderConfig, len(providerConfigs)) + for i, p := range providerConfigs { + providers[i] = core.ProviderConfig{ + Name: p.Name, + APIKey: p.APIKey, + BaseURL: p.BaseURL, + Model: p.Model, + Models: convertProviderModels(p.Models), + Thinking: p.Thinking, + Env: p.Env, + } + } + ps.SetProviders(providers) + + if active, _ := options["provider"].(string); active != "" { + ps.SetActiveProvider(active) + } else { + ps.SetActiveProvider("") + } + return len(providers), nil +} + func convertCoreModels(ms []core.ModelOption) []config.ProviderModelConfig { if len(ms) == 0 { return nil diff --git a/cmd/cc-connect/main_test.go b/cmd/cc-connect/main_test.go index 99099064..0c3b8328 100644 --- a/cmd/cc-connect/main_test.go +++ b/cmd/cc-connect/main_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/chenhg5/cc-connect/config" "github.com/chenhg5/cc-connect/core" ) @@ -33,6 +34,43 @@ func (a *stubMainAgent) GetWorkDir() string { return a.workDir } +type stubMainProviderAgent struct { + stubMainAgent + providers []core.ProviderConfig + activeName string +} + +func (a *stubMainProviderAgent) SetProviders(providers []core.ProviderConfig) { + a.providers = append([]core.ProviderConfig(nil), providers...) +} + +func (a *stubMainProviderAgent) SetActiveProvider(name string) bool { + a.activeName = name + if name == "" { + return true + } + for _, provider := range a.providers { + if provider.Name == name { + return true + } + } + return false +} + +func (a *stubMainProviderAgent) GetActiveProvider() *core.ProviderConfig { + for _, provider := range a.providers { + if provider.Name == a.activeName { + p := provider + return &p + } + } + return nil +} + +func (a *stubMainProviderAgent) ListProviders() []core.ProviderConfig { + return append([]core.ProviderConfig(nil), a.providers...) +} + type stubMainAgentSession struct{} func (s *stubMainAgentSession) Send(string, []core.ImageAttachment, []core.FileAttachment) error { @@ -73,3 +111,135 @@ func TestApplyProjectStateOverride(t *testing.T) { t.Fatalf("agent workDir = %q, want %q", agent.workDir, overrideDir) } } + +func TestConfigureAgentProvidersUsesTopLevelProviders(t *testing.T) { + cfg := &config.Config{ + Providers: []config.ProviderConfig{ + {Name: "openai", APIKey: "sk-openai"}, + {Name: "kimi", APIKey: "sk-kimi"}, + }, + Projects: []config.ProjectConfig{{ + Name: "demo", + Agent: config.AgentConfig{ + Type: "stub-main", + Options: map[string]any{ + "provider": "openai", + }, + Providers: []config.ProviderConfig{ + {Name: "legacy", APIKey: "sk-legacy"}, + }, + }, + Platforms: []config.PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, + }}, + } + + agent := &stubMainProviderAgent{} + updated, err := configureAgentProviders(agent, cfg, "demo", cfg.Projects[0].Agent.Options) + if err != nil { + t.Fatalf("configureAgentProviders() error: %v", err) + } + if updated != 2 { + t.Fatalf("updated = %d, want 2", updated) + } + if len(agent.providers) != 2 { + t.Fatalf("provider count = %d, want 2", len(agent.providers)) + } + if agent.providers[0].Name != "openai" || agent.providers[1].Name != "kimi" { + t.Fatalf("providers = %#v, want top-level providers", agent.providers) + } + if agent.activeName != "openai" { + t.Fatalf("activeName = %q, want openai", agent.activeName) + } +} + +func TestConfigureAgentProvidersFallsBackToLegacyProjectProviders(t *testing.T) { + cfg := &config.Config{ + Projects: []config.ProjectConfig{{ + Name: "demo", + Agent: config.AgentConfig{ + Type: "stub-main", + Options: map[string]any{ + "provider": "legacy", + }, + Providers: []config.ProviderConfig{ + {Name: "legacy", APIKey: "sk-legacy"}, + {Name: "backup", APIKey: "sk-backup"}, + }, + }, + Platforms: []config.PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, + }}, + } + + agent := &stubMainProviderAgent{} + updated, err := configureAgentProviders(agent, cfg, "demo", cfg.Projects[0].Agent.Options) + if err != nil { + t.Fatalf("configureAgentProviders() error: %v", err) + } + if updated != 2 { + t.Fatalf("updated = %d, want 2", updated) + } + if len(agent.providers) != 2 { + t.Fatalf("provider count = %d, want 2", len(agent.providers)) + } + if agent.providers[0].Name != "legacy" || agent.providers[1].Name != "backup" { + t.Fatalf("providers = %#v, want legacy project providers", agent.providers) + } + if agent.activeName != "legacy" { + t.Fatalf("activeName = %q, want legacy", agent.activeName) + } +} + +func TestReloadConfigUsesTopLevelProviders(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + content := ` +[[providers]] +name = "openai" +api_key = "sk-openai" + +[[providers]] +name = "kimi" +api_key = "sk-kimi" + +[[projects]] +name = "demo" + +[projects.agent] +type = "stub-main" + +[projects.agent.options] +provider = "kimi" + +[[projects.agent.providers]] +name = "legacy" +api_key = "sk-legacy" + +[[projects.platforms]] +type = "telegram" + +[projects.platforms.options] +token = "test-token" +` + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + agent := &stubMainProviderAgent{} + engine := core.NewEngine("demo", agent, nil, filepath.Join(dir, "sessions.json"), core.LangEnglish) + result, err := reloadConfig(configPath, "demo", engine) + if err != nil { + t.Fatalf("reloadConfig() error: %v", err) + } + if result.ProvidersUpdated != 2 { + t.Fatalf("ProvidersUpdated = %d, want 2", result.ProvidersUpdated) + } + if len(agent.providers) != 2 { + t.Fatalf("provider count = %d, want 2", len(agent.providers)) + } + if agent.providers[0].Name != "openai" || agent.providers[1].Name != "kimi" { + t.Fatalf("providers = %#v, want top-level providers", agent.providers) + } + if agent.activeName != "kimi" { + t.Fatalf("activeName = %q, want kimi", agent.activeName) + } +} diff --git a/cmd/cc-connect/provider.go b/cmd/cc-connect/provider.go index 3ef88a62..7c87457c 100644 --- a/cmd/cc-connect/provider.go +++ b/cmd/cc-connect/provider.go @@ -41,9 +41,9 @@ func printProviderUsage() { fmt.Println(`Usage: cc-connect provider [options] Commands: - add Add a new API provider to a project - list List providers for a project - remove Remove a provider from a project + add Add a shared API provider + list List effective providers for a project + remove Remove a shared API provider import Import providers from cc-switch Examples: @@ -62,7 +62,7 @@ func initConfigPath(flagValue string) { func runProviderAdd(args []string) { fs := flag.NewFlagSet("provider add", flag.ExitOnError) configFile := fs.String("config", "", "path to config file") - project := fs.String("project", "", "project name (required)") + project := fs.String("project", "", "project name (required for validation)") name := fs.String("name", "", "provider name (required)") apiKey := fs.String("api-key", "", "API key") baseURL := fs.String("base-url", "", "API base URL (optional)") @@ -93,7 +93,7 @@ func runProviderAdd(args []string) { os.Exit(1) } - fmt.Printf("✅ Provider %q added to project %q\n", *name, *project) + fmt.Printf("✅ Provider %q added to shared config for project %q\n", *name, *project) if *baseURL != "" { fmt.Printf(" Base URL: %s\n", *baseURL) } @@ -171,7 +171,7 @@ func listProjectProviders(projectName string) { func runProviderRemove(args []string) { fs := flag.NewFlagSet("provider remove", flag.ExitOnError) configFile := fs.String("config", "", "path to config file") - project := fs.String("project", "", "project name (required)") + project := fs.String("project", "", "project name (required for validation)") name := fs.String("name", "", "provider name (required)") _ = fs.Parse(args) @@ -188,7 +188,7 @@ func runProviderRemove(args []string) { os.Exit(1) } - fmt.Printf("✅ Provider %q removed from project %q\n", *name, *project) + fmt.Printf("✅ Provider %q removed from shared config for project %q\n", *name, *project) } // ── Import from cc-switch ────────────────────────────────────── diff --git a/config.example.toml b/config.example.toml index fee30039..831ef991 100644 --- a/config.example.toml +++ b/config.example.toml @@ -563,19 +563,23 @@ mode = "default" # "default" | "acceptEdits" (edit) | "plan" | "auto" | "bypassP # Note: When using router, provider settings below are ignored as the router handles model selection. # 注意:使用 router 时,下方的 provider 设置将被忽略,因为 router 负责模型选择。 -# Active provider (matches a name in [[projects.agent.providers]]) -# 当前激活的 provider(对应下方 providers 中的 name) +# Active provider (matches a name in top-level [[providers]]) +# 当前激活的 provider(对应顶层 [[providers]] 中的 name) # provider = "anthropic" -# API Providers — switch between them via /provider command in chat +# API Providers — define once at top level and reuse across projects +# Legacy [[projects.agent.providers]] is still supported for backward compatibility, +# but new configs should prefer top-level [[providers]]. # or via CLI: cc-connect provider add --project my-backend --name relay --api-key sk-xxx -# API Provider 管理 — 可通过聊天命令 /provider 或 CLI 命令切换 +# API Provider 管理 — 顶层定义一次,多项目复用 +# 旧的 [[projects.agent.providers]] 仍兼容读取,但新配置建议使用顶层 [[providers]] +# 可通过聊天命令 /provider 或 CLI 命令切换 # -# [[projects.agent.providers]] +# [[providers]] # name = "anthropic" # api_key = "sk-ant-xxx" # -# [[projects.agent.providers]] +# [[providers]] # name = "relay" # api_key = "sk-xxx" # base_url = "https://api.relay-service.com" @@ -584,13 +588,13 @@ mode = "default" # "default" | "acceptEdits" (edit) | "plan" | "auto" | "bypassP # # /model switch [alias] switches to the model; omit to auto-fetch from the API. # # 可选:预先配置 /model 命令显示的可用模型列表,格式为 alias - model # # 使用 /model switch [alias] 切换模型;留空则自动从 API 获取 -# [[projects.agent.providers.models]] +# [[providers.models]] # model = "claude-sonnet-4-20250514" # alias = "sonnet" -# [[projects.agent.providers.models]] +# [[providers.models]] # model = "claude-opus-4-20250514" # alias = "opus" -# [[projects.agent.providers.models]] +# [[providers.models]] # model = "claude-haiku-3-5-20241022" # alias = "haiku" # @@ -599,7 +603,7 @@ mode = "default" # "default" | "acceptEdits" (edit) | "plan" | "auto" | "bypassP # # Set thinking = "disabled" to auto-rewrite via a local proxy. # # 部分第三方 Provider(如硅基流动)不支持 Claude 的 adaptive thinking, # # 设置 thinking = "disabled" 后 cc-connect 会通过本地代理自动改写请求。 -# [[projects.agent.providers]] +# [[providers]] # name = "siliconflow" # api_key = "sk-xxx" # base_url = "https://api.siliconflow.cn" @@ -610,28 +614,28 @@ mode = "default" # "default" | "acceptEdits" (edit) | "plan" | "auto" | "bypassP # # MiniMax — 兼容 OpenAI 接口的大模型,支持 1M 超长上下文 # # Models: MiniMax-M2.7 (flagship, 1M context), MiniMax-M2.7-highspeed, MiniMax-M2.5, MiniMax-M2.5-highspeed # # Docs: https://platform.minimaxi.com -# [[projects.agent.providers]] +# [[providers]] # name = "minimax" # api_key = "your-minimax-api-key" # base_url = "https://api.minimax.io/v1" # model = "MiniMax-M2.7" # thinking = "disabled" -# [[projects.agent.providers.models]] +# [[providers.models]] # model = "MiniMax-M2.7" # alias = "m27" -# [[projects.agent.providers.models]] +# [[providers.models]] # model = "MiniMax-M2.7-highspeed" # alias = "m27fast" -# [[projects.agent.providers.models]] +# [[providers.models]] # model = "MiniMax-M2.5" # alias = "m25" -# [[projects.agent.providers.models]] +# [[providers.models]] # model = "MiniMax-M2.5-highspeed" # alias = "m25fast" # # # For special setups (Bedrock, Vertex, etc.), use the env map: # # 特殊环境(Bedrock、Vertex 等)使用 env 字段: -# [[projects.agent.providers]] +# [[providers]] # name = "bedrock" # env = { CLAUDE_CODE_USE_BEDROCK = "1", AWS_PROFILE = "bedrock" } @@ -965,12 +969,12 @@ app_secret = "your-feishu-app-secret" # model = "gemini-2.5-flash" # # # Provider with API key / 使用 API Key 的 Provider -# [[projects.agent.providers]] +# [[providers]] # name = "google" # api_key = "your-gemini-api-key" # GEMINI_API_KEY # # # Vertex AI provider / Vertex AI 提供商 -# [[projects.agent.providers]] +# [[providers]] # name = "vertex" # env = { GOOGLE_API_KEY = "your-key", GOOGLE_GENAI_USE_VERTEXAI = "true" } # @@ -1011,14 +1015,14 @@ app_secret = "your-feishu-app-secret" # Optional: specify Codex reasoning effort / 可选:指定 Codex 推理强度 # reasoning_effort = "high" # "minimal" | "low" | "medium" | "high" | "xhigh" # -# # Active provider / 当前激活的 provider +# # Active provider (matches top-level [[providers]]) / 当前激活的 provider(对应顶层 [[providers]]) # # provider = "openai" # -# [[projects.agent.providers]] +# [[providers]] # name = "openai" # api_key = "sk-xxx" # -# [[projects.agent.providers]] +# [[providers]] # name = "custom" # api_key = "sk-xxx" # base_url = "https://custom-api.com/v1" @@ -1026,7 +1030,7 @@ app_secret = "your-feishu-app-secret" # # # MiniMax — OpenAI-compatible, works natively with Codex # # MiniMax — 兼容 OpenAI 接口,可直接用于 Codex -# [[projects.agent.providers]] +# [[providers]] # name = "minimax" # api_key = "your-minimax-api-key" # base_url = "https://api.minimax.io/v1" @@ -1239,10 +1243,10 @@ app_secret = "your-feishu-app-secret" # Optional: specify a model / 可选:指定模型 # model = "Qwen3-Coder" # -# # Active provider / 当前激活的 provider +# # Active provider (matches top-level [[providers]]) / 当前激活的 provider(对应顶层 [[providers]]) # # provider = "iflow-openai-compatible" # -# [[projects.agent.providers]] +# [[providers]] # name = "iflow-openai-compatible" # api_key = "sk-xxx" # base_url = "https://api.example.com/v1" diff --git a/config/config.go b/config/config.go index 33e2b379..815a78a2 100644 --- a/config/config.go +++ b/config/config.go @@ -18,27 +18,28 @@ var configMu sync.Mutex var ConfigPath string type Config struct { - DataDir string `toml:"data_dir"` // session store directory, default ~/.cc-connect - AttachmentSend string `toml:"attachment_send"` - Projects []ProjectConfig `toml:"projects"` - Commands []CommandConfig `toml:"commands"` // global custom slash commands - Aliases []AliasConfig `toml:"aliases"` // global command aliases - BannedWords []string `toml:"banned_words"` // messages containing any of these words are blocked - Log LogConfig `toml:"log"` - Language string `toml:"language"` // "en" or "zh", default is "en" - Speech SpeechConfig `toml:"speech"` - TTS TTSConfig `toml:"tts"` - Display DisplayConfig `toml:"display"` - StreamPreview StreamPreviewConfig `toml:"stream_preview"` // real-time streaming preview - RateLimit RateLimitConfig `toml:"rate_limit"` // per-session rate limiting - OutgoingRateLimit OutgoingRateLimitConfig `toml:"outgoing_rate_limit"` // outgoing message throttling - Relay RelayConfig `toml:"relay"` // bot-to-bot relay behavior - Quiet *bool `toml:"quiet,omitempty"` // global default for quiet mode; project-level overrides this - Cron CronConfig `toml:"cron"` - Webhook WebhookConfig `toml:"webhook"` - Bridge BridgeConfig `toml:"bridge"` - Management ManagementConfig `toml:"management"` - IdleTimeoutMins *int `toml:"idle_timeout_mins,omitempty"` // max minutes between agent events; 0 = no timeout; default 120 + DataDir string `toml:"data_dir"` // session store directory, default ~/.cc-connect + AttachmentSend string `toml:"attachment_send"` + Providers []ProviderConfig `toml:"providers"` + Projects []ProjectConfig `toml:"projects"` + Commands []CommandConfig `toml:"commands"` // global custom slash commands + Aliases []AliasConfig `toml:"aliases"` // global command aliases + BannedWords []string `toml:"banned_words"` // messages containing any of these words are blocked + Log LogConfig `toml:"log"` + Language string `toml:"language"` // "en" or "zh", default is "en" + Speech SpeechConfig `toml:"speech"` + TTS TTSConfig `toml:"tts"` + Display DisplayConfig `toml:"display"` + StreamPreview StreamPreviewConfig `toml:"stream_preview"` // real-time streaming preview + RateLimit RateLimitConfig `toml:"rate_limit"` // per-session rate limiting + OutgoingRateLimit OutgoingRateLimitConfig `toml:"outgoing_rate_limit"` // outgoing message throttling + Relay RelayConfig `toml:"relay"` // bot-to-bot relay behavior + Quiet *bool `toml:"quiet,omitempty"` // global default for quiet mode; project-level overrides this + Cron CronConfig `toml:"cron"` + Webhook WebhookConfig `toml:"webhook"` + Bridge BridgeConfig `toml:"bridge"` + Management ManagementConfig `toml:"management"` + IdleTimeoutMins *int `toml:"idle_timeout_mins,omitempty"` // max minutes between agent events; 0 = no timeout; default 120 } // CronConfig controls cron job behavior. @@ -97,7 +98,7 @@ type RateLimitConfig struct { type OutgoingRateLimitConfig struct { MaxPerSecond *float64 `toml:"max_per_second"` // messages per second; 0 = unlimited (default) Burst *int `toml:"burst"` // max burst size; default = ceil(max_per_second) - Platforms map[string]OutgoingRateLimitPlatConfig `toml:"platforms"` // per-platform overrides keyed by platform type name + Platforms map[string]OutgoingRateLimitPlatConfig `toml:"platforms"` // per-platform overrides keyed by platform type name } // OutgoingRateLimitPlatConfig is a per-platform override for outgoing rate limiting. @@ -208,7 +209,7 @@ type ProjectConfig struct { type AgentConfig struct { Type string `toml:"type"` Options map[string]any `toml:"options"` - Providers []ProviderConfig `toml:"providers"` + Providers []ProviderConfig `toml:"providers"` // legacy per-project providers; top-level Config.Providers is preferred } // ProviderModelConfig defines a selectable model entry for a provider, @@ -295,6 +296,9 @@ func (c *Config) validate() error { if len(c.Projects) == 0 { return fmt.Errorf("config: at least one [[projects]] entry is required") } + if dup := firstDuplicateProviderName(c.Providers); dup != "" { + return fmt.Errorf("config: duplicate provider name %q in top-level providers", dup) + } for i, proj := range c.Projects { prefix := fmt.Sprintf("projects[%d]", i) if proj.Name == "" { @@ -319,6 +323,9 @@ func (c *Config) validate() error { return fmt.Errorf("project %q: multi-workspace mode conflicts with agent work_dir (use base_dir instead)", proj.Name) } } + if active := stringOption(proj.Agent.Options["provider"]); active != "" && !hasProviderNamed(c.effectiveProvidersForProject(proj), active) { + return fmt.Errorf("config: %s.agent.options.provider %q not found in effective providers", prefix, active) + } if err := validateUsersConfig(prefix, proj.Users); err != nil { return err } @@ -363,6 +370,87 @@ func validateUsersConfig(prefix string, u *UsersConfig) error { return nil } +func (c *Config) effectiveProvidersForProject(proj ProjectConfig) []ProviderConfig { + if len(c.Providers) > 0 { + return cloneProviderConfigs(c.Providers) + } + return cloneProviderConfigs(proj.Agent.Providers) +} + +func findProject(cfg *Config, projectName string) (*ProjectConfig, error) { + for i := range cfg.Projects { + if cfg.Projects[i].Name == projectName { + return &cfg.Projects[i], nil + } + } + return nil, fmt.Errorf("project %q not found in config", projectName) +} + +func hasProviderNamed(providers []ProviderConfig, name string) bool { + for _, provider := range providers { + if provider.Name == name { + return true + } + } + return false +} + +func firstDuplicateProviderName(providers []ProviderConfig) string { + seen := make(map[string]struct{}, len(providers)) + for _, provider := range providers { + if provider.Name == "" { + continue + } + if _, ok := seen[provider.Name]; ok { + return provider.Name + } + seen[provider.Name] = struct{}{} + } + return "" +} + +func cloneProviderConfigs(providers []ProviderConfig) []ProviderConfig { + if len(providers) == 0 { + return nil + } + out := make([]ProviderConfig, len(providers)) + for i, provider := range providers { + out[i] = provider + if len(provider.Models) > 0 { + out[i].Models = append([]ProviderModelConfig(nil), provider.Models...) + } + if len(provider.Env) > 0 { + out[i].Env = make(map[string]string, len(provider.Env)) + for k, v := range provider.Env { + out[i].Env[k] = v + } + } + } + return out +} + +func ensureTopLevelProvidersForProject(cfg *Config, projectName string) error { + proj, err := findProject(cfg, projectName) + if err != nil { + return err + } + if len(cfg.Providers) > 0 { + return nil + } + cfg.Providers = cloneProviderConfigs(cfg.effectiveProvidersForProject(*proj)) + return nil +} + +// GetEffectiveProjectProviders returns the provider list visible to a project. +// Top-level providers take precedence over legacy project-local providers. +func GetEffectiveProjectProviders(cfg *Config, projectName string) ([]ProviderConfig, error) { + proj, err := findProject(cfg, projectName) + if err != nil { + return nil, err + } + return cfg.effectiveProvidersForProject(*proj), nil +} + // SaveActiveProvider persists the active provider name for a project. func SaveActiveProvider(projectName, providerName string) error { configMu.Lock() @@ -378,15 +466,14 @@ func SaveActiveProvider(projectName, providerName string) error { if err := toml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config: %w", err) } - for i := range cfg.Projects { - if cfg.Projects[i].Name == projectName { - if cfg.Projects[i].Agent.Options == nil { - cfg.Projects[i].Agent.Options = make(map[string]any) - } - cfg.Projects[i].Agent.Options["provider"] = providerName - break - } + proj, err := findProject(cfg, projectName) + if err != nil { + return err } + if proj.Agent.Options == nil { + proj.Agent.Options = make(map[string]any) + } + proj.Agent.Options["provider"] = providerName return saveConfig(cfg) } @@ -406,19 +493,16 @@ func SaveProviderModel(projectName, providerName, model string) error { return fmt.Errorf("parse config: %w", err) } - for i := range cfg.Projects { - if cfg.Projects[i].Name != projectName { - continue - } - for j := range cfg.Projects[i].Agent.Providers { - if cfg.Projects[i].Agent.Providers[j].Name == providerName { - cfg.Projects[i].Agent.Providers[j].Model = model - return saveConfig(cfg) - } + if err := ensureTopLevelProvidersForProject(cfg, projectName); err != nil { + return err + } + for i := range cfg.Providers { + if cfg.Providers[i].Name == providerName { + cfg.Providers[i].Model = model + return saveConfig(cfg) } - return fmt.Errorf("provider %q not found in project %q", providerName, projectName) } - return fmt.Errorf("project %q not found in config", projectName) + return fmt.Errorf("provider %q not found in project %q", providerName, projectName) } // SaveAgentModel persists the selected default model for a project's agent. @@ -466,22 +550,15 @@ func AddProviderToConfig(projectName string, provider ProviderConfig) error { return fmt.Errorf("parse config: %w", err) } - found := false - for i := range cfg.Projects { - if cfg.Projects[i].Name == projectName { - for _, existing := range cfg.Projects[i].Agent.Providers { - if existing.Name == provider.Name { - return fmt.Errorf("provider %q already exists in project %q", provider.Name, projectName) - } - } - cfg.Projects[i].Agent.Providers = append(cfg.Projects[i].Agent.Providers, provider) - found = true - break - } + if err := ensureTopLevelProvidersForProject(cfg, projectName); err != nil { + return err } - if !found { - return fmt.Errorf("project %q not found in config", projectName) + for _, existing := range cfg.Providers { + if existing.Name == provider.Name { + return fmt.Errorf("provider %q already exists in project %q", provider.Name, projectName) + } } + cfg.Providers = append(cfg.Providers, provider) return saveConfig(cfg) } @@ -501,17 +578,14 @@ func RemoveProviderFromConfig(projectName, providerName string) error { return fmt.Errorf("parse config: %w", err) } + if err := ensureTopLevelProvidersForProject(cfg, projectName); err != nil { + return err + } found := false - for i := range cfg.Projects { - if cfg.Projects[i].Name == projectName { - providers := cfg.Projects[i].Agent.Providers - for j := range providers { - if providers[j].Name == providerName { - cfg.Projects[i].Agent.Providers = append(providers[:j], providers[j+1:]...) - found = true - break - } - } + for i := range cfg.Providers { + if cfg.Providers[i].Name == providerName { + cfg.Providers = append(cfg.Providers[:i], cfg.Providers[i+1:]...) + found = true break } } @@ -815,13 +889,16 @@ func GetProjectProviders(projectName string) ([]ProviderConfig, string, error) { if err := toml.Unmarshal(data, cfg); err != nil { return nil, "", fmt.Errorf("parse config: %w", err) } - for _, p := range cfg.Projects { - if p.Name == projectName { - active, _ := p.Agent.Options["provider"].(string) - return p.Agent.Providers, active, nil - } + proj, err := findProject(cfg, projectName) + if err != nil { + return nil, "", err + } + providers, err := GetEffectiveProjectProviders(cfg, projectName) + if err != nil { + return nil, "", err } - return nil, "", fmt.Errorf("project %q not found", projectName) + active, _ := proj.Agent.Options["provider"].(string) + return providers, active, nil } // FeishuCredentialUpdateOptions controls how Feishu/Lark platform credentials diff --git a/config/config_test.go b/config/config_test.go index 3453e847..e247b850 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -102,6 +102,32 @@ func TestConfigValidate(t *testing.T) { Projects: []ProjectConfig{validProject("demo")}, }, }, + { + name: "rejects duplicate top level provider names", + cfg: Config{ + Providers: []ProviderConfig{ + {Name: "openai"}, + {Name: "openai"}, + }, + Projects: []ProjectConfig{validProject("demo")}, + }, + wantErr: `duplicate provider name "openai"`, + }, + { + name: "rejects unknown active provider against effective list", + cfg: Config{ + Providers: []ProviderConfig{{Name: "openai"}}, + Projects: []ProjectConfig{ + func() ProjectConfig { + p := validProject("demo") + p.Agent.Options["provider"] = "missing" + p.Agent.Providers = []ProviderConfig{{Name: "legacy"}} + return p + }(), + }, + }, + wantErr: `agent.options.provider "missing" not found in effective providers`, + }, } for _, tt := range tests { @@ -164,7 +190,7 @@ func TestSaveLanguage(t *testing.T) { } func TestProviderConfig_SaveActiveProviderAndGetProjectProviders(t *testing.T) { - writeTestConfig(t, providerConfigTOML) + writeTestConfig(t, topLevelProviderConfigTOML) if err := SaveActiveProvider("demo", "backup"); err != nil { t.Fatalf("SaveActiveProvider() error: %v", err) @@ -182,9 +208,45 @@ func TestProviderConfig_SaveActiveProviderAndGetProjectProviders(t *testing.T) { } } -func TestProviderConfig_AddAndRemove(t *testing.T) { +func TestProviderConfig_GetProjectProvidersFallsBackToLegacyProjectProviders(t *testing.T) { writeTestConfig(t, providerConfigTOML) + providers, active, err := GetProjectProviders("demo") + if err != nil { + t.Fatalf("GetProjectProviders() error: %v", err) + } + if active != "primary" { + t.Fatalf("active provider = %q, want primary", active) + } + if len(providers) != 2 { + t.Fatalf("provider count = %d, want 2", len(providers)) + } + if providers[0].Name != "primary" || providers[1].Name != "backup" { + t.Fatalf("providers = %#v, want legacy providers", providers) + } +} + +func TestProviderConfig_TopLevelProvidersTakePrecedenceOverLegacyProjectProviders(t *testing.T) { + writeTestConfig(t, mixedProviderConfigTOML) + + providers, active, err := GetProjectProviders("demo") + if err != nil { + t.Fatalf("GetProjectProviders() error: %v", err) + } + if active != "shared-primary" { + t.Fatalf("active provider = %q, want shared-primary", active) + } + if len(providers) != 2 { + t.Fatalf("provider count = %d, want 2", len(providers)) + } + if providers[0].Name != "shared-primary" || providers[1].Name != "shared-backup" { + t.Fatalf("providers = %#v, want top-level providers", providers) + } +} + +func TestProviderConfig_AddAndRemove(t *testing.T) { + writeTestConfig(t, topLevelProviderConfigTOML) + newProvider := ProviderConfig{Name: "relay", APIKey: "sk-relay", BaseURL: "https://example.com"} if err := AddProviderToConfig("demo", newProvider); err != nil { t.Fatalf("AddProviderToConfig() error: %v", err) @@ -194,8 +256,8 @@ func TestProviderConfig_AddAndRemove(t *testing.T) { } cfg := readTestConfig(t) - if len(cfg.Projects[0].Agent.Providers) != 3 { - t.Fatalf("provider count after add = %d, want 3", len(cfg.Projects[0].Agent.Providers)) + if len(cfg.Providers) != 3 { + t.Fatalf("provider count after add = %d, want 3", len(cfg.Providers)) } if err := RemoveProviderFromConfig("demo", "relay"); err != nil { @@ -206,15 +268,37 @@ func TestProviderConfig_AddAndRemove(t *testing.T) { } } -func TestProviderConfig_SaveProviderModel(t *testing.T) { +func TestProviderConfig_WritesPromoteLegacyProjectProvidersToTopLevel(t *testing.T) { writeTestConfig(t, providerConfigTOML) + if err := AddProviderToConfig("demo", ProviderConfig{Name: "relay", APIKey: "sk-relay"}); err != nil { + t.Fatalf("AddProviderToConfig() error: %v", err) + } + if err := SaveProviderModel("demo", "primary", "gpt-5.4"); err != nil { + t.Fatalf("SaveProviderModel() error: %v", err) + } + + cfg := readTestConfig(t) + if len(cfg.Providers) != 3 { + t.Fatalf("top-level provider count = %d, want 3", len(cfg.Providers)) + } + if got := cfg.Providers[0].Model; got != "gpt-5.4" { + t.Fatalf("cfg.Providers[0].Model = %q, want gpt-5.4", got) + } + if len(cfg.Projects[0].Agent.Providers) != 2 { + t.Fatalf("legacy project provider count = %d, want unchanged 2", len(cfg.Projects[0].Agent.Providers)) + } +} + +func TestProviderConfig_SaveProviderModel(t *testing.T) { + writeTestConfig(t, topLevelProviderConfigTOML) + if err := SaveProviderModel("demo", "primary", "gpt-5.4"); err != nil { t.Fatalf("SaveProviderModel() error: %v", err) } cfg := readTestConfig(t) - if got := cfg.Projects[0].Agent.Providers[0].Model; got != "gpt-5.4" { + if got := cfg.Providers[0].Model; got != "gpt-5.4" { t.Fatalf("provider model = %q, want gpt-5.4", got) } if err := SaveProviderModel("demo", "missing", "gpt-4.1"); err == nil { @@ -223,7 +307,7 @@ func TestProviderConfig_SaveProviderModel(t *testing.T) { } func TestSaveAgentModel(t *testing.T) { - writeTestConfig(t, providerConfigTOML) + writeTestConfig(t, topLevelProviderConfigTOML) if err := SaveAgentModel("demo", "gpt-5.4"); err != nil { t.Fatalf("SaveAgentModel() error: %v", err) @@ -239,8 +323,8 @@ func TestSaveAgentModel(t *testing.T) { if got, _ := cfg.Projects[0].Agent.Options["provider"].(string); got != "primary" { t.Fatalf("agent.options.provider = %q, want primary", got) } - if len(cfg.Projects[0].Agent.Providers) != 2 { - t.Fatalf("provider count = %d, want 2", len(cfg.Projects[0].Agent.Providers)) + if len(cfg.Providers) != 2 { + t.Fatalf("provider count = %d, want 2", len(cfg.Providers)) } } @@ -799,6 +883,66 @@ type = "telegram" token = "test-token" ` +const topLevelProviderConfigTOML = ` +[[providers]] +name = "primary" +api_key = "sk-primary" + +[[providers]] +name = "backup" +api_key = "sk-backup" + +[[projects]] +name = "demo" + +[projects.agent] +type = "claudecode" + +[projects.agent.options] +mode = "default" +provider = "primary" + +[[projects.platforms]] +type = "telegram" + +[projects.platforms.options] +token = "test-token" +` + +const mixedProviderConfigTOML = ` +[[providers]] +name = "shared-primary" +api_key = "sk-shared-primary" + +[[providers]] +name = "shared-backup" +api_key = "sk-shared-backup" + +[[projects]] +name = "demo" + +[projects.agent] +type = "claudecode" + +[projects.agent.options] +mode = "default" +provider = "shared-primary" + +[[projects.agent.providers]] +name = "primary" +api_key = "sk-primary" + +[[projects.agent.providers]] +name = "backup" +api_key = "sk-backup" + +[[projects.platforms]] +type = "telegram" + +[projects.platforms.options] +token = "test-token" +` + const feishuConfigFixture = ` [[projects]] name = "alpha" @@ -899,10 +1043,10 @@ func TestValidateUsersConfig(t *testing.T) { name: "nil users is valid", cfg: Config{ Projects: []ProjectConfig{{ - Name: "p1", - Agent: AgentConfig{Type: "codex"}, + Name: "p1", + Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, - Users: nil, + Users: nil, }}, }, wantErr: "", @@ -911,10 +1055,10 @@ func TestValidateUsersConfig(t *testing.T) { name: "empty roles", cfg: Config{ Projects: []ProjectConfig{{ - Name: "p1", - Agent: AgentConfig{Type: "codex"}, + Name: "p1", + Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, - Users: &UsersConfig{Roles: map[string]RoleConfig{}}, + Users: &UsersConfig{Roles: map[string]RoleConfig{}}, }}, }, wantErr: `no roles defined`, @@ -923,8 +1067,8 @@ func TestValidateUsersConfig(t *testing.T) { name: "empty user_ids in role", cfg: Config{ Projects: []ProjectConfig{{ - Name: "p1", - Agent: AgentConfig{Type: "codex"}, + Name: "p1", + Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ Roles: map[string]RoleConfig{ @@ -939,13 +1083,13 @@ func TestValidateUsersConfig(t *testing.T) { name: "duplicate user in different roles", cfg: Config{ Projects: []ProjectConfig{{ - Name: "p1", - Agent: AgentConfig{Type: "codex"}, + Name: "p1", + Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ Roles: map[string]RoleConfig{ - "admin": {UserIDs: []string{"user1"}}, - "member": {UserIDs: []string{"user1"}}, + "admin": {UserIDs: []string{"user1"}}, + "member": {UserIDs: []string{"user1"}}, }, }, }}, @@ -956,8 +1100,8 @@ func TestValidateUsersConfig(t *testing.T) { name: "wildcard in multiple roles", cfg: Config{ Projects: []ProjectConfig{{ - Name: "p1", - Agent: AgentConfig{Type: "codex"}, + Name: "p1", + Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ Roles: map[string]RoleConfig{ @@ -973,8 +1117,8 @@ func TestValidateUsersConfig(t *testing.T) { name: "default_role not matching any role", cfg: Config{ Projects: []ProjectConfig{{ - Name: "p1", - Agent: AgentConfig{Type: "codex"}, + Name: "p1", + Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ DefaultRole: "superadmin", @@ -990,8 +1134,8 @@ func TestValidateUsersConfig(t *testing.T) { name: "valid users config", cfg: Config{ Projects: []ProjectConfig{{ - Name: "p1", - Agent: AgentConfig{Type: "codex"}, + Name: "p1", + Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ DefaultRole: "member", @@ -1008,8 +1152,8 @@ func TestValidateUsersConfig(t *testing.T) { name: "valid with wildcard in one role only", cfg: Config{ Projects: []ProjectConfig{{ - Name: "p1", - Agent: AgentConfig{Type: "codex"}, + Name: "p1", + Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ Roles: map[string]RoleConfig{ @@ -1173,7 +1317,7 @@ func TestCloneAgentConfig(t *testing.T) { t.Fatalf("Providers length = %d, want 1", len(got.Providers)) } p := got.Providers[0] - if p.Name != "openai" || p.APIKey != "sk-test" || p.BaseURL != "https://api.openai.com" || p.Model != "gpt-4" { + if p.Name != "openai" || p.APIKey != "sk-test" || p.BaseURL != "https://api.openai.com" || p.Model != "gpt-4" { t.Errorf("Provider fields not cloned correctly: %+v", p) } if p.Env["DEBUG"] != "1" { diff --git a/docs/usage.md b/docs/usage.md index 494430ef..48a289b7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -127,41 +127,43 @@ Switch between API providers at runtime without restart. work_dir = "/path/to/project" provider = "anthropic" # active provider -[[projects.agent.providers]] +[[providers]] name = "anthropic" api_key = "sk-ant-xxx" -[[projects.agent.providers]] +[[providers]] name = "relay" api_key = "sk-xxx" base_url = "https://api.relay-service.com" model = "claude-sonnet-4-20250514" -[[projects.agent.providers.models]] +[[providers.models]] model = "claude-sonnet-4-20250514" alias = "sonnet" -[[projects.agent.providers.models]] +[[providers.models]] model = "claude-opus-4-20250514" alias = "opus" -[[projects.agent.providers.models]] +[[providers.models]] model = "claude-haiku-3-5-20241022" alias = "haiku" # MiniMax — OpenAI-compatible, 1M context -[[projects.agent.providers]] +[[providers]] name = "minimax" api_key = "your-minimax-api-key" base_url = "https://api.minimax.io/v1" model = "MiniMax-M2.7" # For Bedrock, Vertex, etc. -[[projects.agent.providers]] +[[providers]] name = "bedrock" env = { CLAUDE_CODE_USE_BEDROCK = "1", AWS_PROFILE = "bedrock" } ``` +Top-level `[[providers]]` are shared across projects. Legacy `[[projects.agent.providers]]` is still loaded for backward compatibility, but new configs should prefer the top-level form. + ### CLI Commands ```bash @@ -201,19 +203,19 @@ Pre-configure a list of selectable models per provider using `[[providers.models ### Configure Models ```toml -[[projects.agent.providers]] +[[providers]] name = "openai" api_key = "sk-xxx" -[[projects.agent.providers.models]] +[[providers.models]] model = "gpt-5.3-codex" alias = "codex" -[[projects.agent.providers.models]] +[[providers.models]] model = "gpt-5.4" alias = "gpt" -[[projects.agent.providers.models]] +[[providers.models]] model = "gpt-5.3-codex-spark" alias = "spark" ``` @@ -753,6 +755,10 @@ work_dir = "/path/to/project" mode = "default" provider = "anthropic" +[[providers]] +name = "anthropic" +api_key = "sk-ant-xxx" + [[projects.platforms]] type = "feishu" # or dingtalk, telegram, slack, discord, wecom, weixin, line, qq, qqbot diff --git a/docs/usage.zh-CN.md b/docs/usage.zh-CN.md index b434fe4f..1c094537 100644 --- a/docs/usage.zh-CN.md +++ b/docs/usage.zh-CN.md @@ -127,41 +127,43 @@ mode = "default" work_dir = "/path/to/project" provider = "anthropic" -[[projects.agent.providers]] +[[providers]] name = "anthropic" api_key = "sk-ant-xxx" -[[projects.agent.providers]] +[[providers]] name = "relay" api_key = "sk-xxx" base_url = "https://api.relay-service.com" model = "claude-sonnet-4-20250514" -[[projects.agent.providers.models]] +[[providers.models]] model = "claude-sonnet-4-20250514" alias = "sonnet" -[[projects.agent.providers.models]] +[[providers.models]] model = "claude-opus-4-20250514" alias = "opus" -[[projects.agent.providers.models]] +[[providers.models]] model = "claude-haiku-3-5-20241022" alias = "haiku" # MiniMax — 兼容 OpenAI 接口,1M 超长上下文 -[[projects.agent.providers]] +[[providers]] name = "minimax" api_key = "your-minimax-api-key" base_url = "https://api.minimax.io/v1" model = "MiniMax-M2.7" # Bedrock、Vertex 等 -[[projects.agent.providers]] +[[providers]] name = "bedrock" env = { CLAUDE_CODE_USE_BEDROCK = "1", AWS_PROFILE = "bedrock" } ``` +顶层 `[[providers]]` 可以被多个项目复用。旧的 `[[projects.agent.providers]]` 仍兼容读取,但新配置建议优先使用顶层写法。 + ### CLI 命令 ```bash @@ -201,19 +203,19 @@ cc-connect provider import --project my-backend # 从 cc-switch 导入 ### 配置模型 ```toml -[[projects.agent.providers]] +[[providers]] name = "openai" api_key = "sk-xxx" -[[projects.agent.providers.models]] +[[providers.models]] model = "gpt-5.3-codex" alias = "codex" -[[projects.agent.providers.models]] +[[providers.models]] model = "gpt-5.4" alias = "gpt" -[[projects.agent.providers.models]] +[[providers.models]] model = "gpt-5.3-codex-spark" alias = "spark" ``` @@ -766,6 +768,10 @@ work_dir = "/path/to/project" mode = "default" provider = "anthropic" +[[providers]] +name = "anthropic" +api_key = "sk-ant-xxx" + [[projects.platforms]] type = "feishu" # 或 dingtalk, telegram, slack, discord, wecom, weixin, line, qq, qqbot From a74464a7b8d3a7ec85ec3a0f75074a47eaa38133 Mon Sep 17 00:00:00 2001 From: Deeka Wong <8337659+huangdijia@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:59:39 +0800 Subject: [PATCH 2/2] fix(config): restore reset_on_idle validation --- config/config.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/config.go b/config/config.go index c4b26e8e..a0ce5939 100644 --- a/config/config.go +++ b/config/config.go @@ -332,6 +332,9 @@ func (c *Config) validate() error { if active := stringOption(proj.Agent.Options["provider"]); active != "" && !hasProviderNamed(c.effectiveProvidersForProject(proj), active) { return fmt.Errorf("config: %s.agent.options.provider %q not found in effective providers", prefix, active) } + if proj.ResetOnIdleMins != nil && *proj.ResetOnIdleMins < 0 { + return fmt.Errorf("config: %s.reset_on_idle_mins must be >= 0", prefix) + } if err := validateUsersConfig(prefix, proj.Users); err != nil { return err }