diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index ea7ca3f5..58499fa5 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -81,16 +81,8 @@ func runAutoUpdater(ctx context.Context) { runOnce := func() { cfg := currentConfigPtr.Load() - if cfg == nil { - log.Debug("management asset auto-updater skipped: config not yet available") - return - } - if cfg.RemoteManagement.DisableControlPanel { - log.Debug("management asset auto-updater skipped: control panel disabled") - return - } - if cfg.RemoteManagement.DisableAutoUpdatePanel { - log.Debug("management asset auto-updater skipped: disable-auto-update-panel is enabled") + if reason, skip := autoUpdateSkipReason(cfg); skip { + log.Debugf("management asset auto-updater skipped: %s", reason) return } @@ -111,6 +103,22 @@ func runAutoUpdater(ctx context.Context) { } } +func autoUpdateSkipReason(cfg *config.Config) (string, bool) { + if cfg == nil { + return "config not yet available", true + } + if cfg.Home.Enabled { + return "cluster mode enabled", true + } + if cfg.RemoteManagement.DisableControlPanel { + return "control panel disabled", true + } + if cfg.RemoteManagement.DisableAutoUpdatePanel { + return "disable-auto-update-panel is enabled", true + } + return "", false +} + func newHTTPClient(proxyURL string) *http.Client { client := &http.Client{Timeout: 15 * time.Second} diff --git a/internal/managementasset/updater_test.go b/internal/managementasset/updater_test.go new file mode 100644 index 00000000..82fdb291 --- /dev/null +++ b/internal/managementasset/updater_test.go @@ -0,0 +1,62 @@ +package managementasset + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestAutoUpdateSkipReason(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + wantReason string + wantSkip bool + }{ + { + name: "nil config", + cfg: nil, + wantReason: "config not yet available", + wantSkip: true, + }, + { + name: "cluster mode", + cfg: &config.Config{ + Home: config.HomeConfig{Enabled: true}, + }, + wantReason: "cluster mode enabled", + wantSkip: true, + }, + { + name: "control panel disabled", + cfg: &config.Config{ + RemoteManagement: config.RemoteManagement{DisableControlPanel: true}, + }, + wantReason: "control panel disabled", + wantSkip: true, + }, + { + name: "auto update disabled", + cfg: &config.Config{ + RemoteManagement: config.RemoteManagement{DisableAutoUpdatePanel: true}, + }, + wantReason: "disable-auto-update-panel is enabled", + wantSkip: true, + }, + { + name: "enabled", + cfg: &config.Config{}, + wantReason: "", + wantSkip: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotReason, gotSkip := autoUpdateSkipReason(tt.cfg) + if gotReason != tt.wantReason || gotSkip != tt.wantSkip { + t.Fatalf("autoUpdateSkipReason() = (%q, %t), want (%q, %t)", gotReason, gotSkip, tt.wantReason, tt.wantSkip) + } + }) + } +} -- 2.51.2 From 4cbd50049e05a3b322cea1e69a40fbf9f01cfcec Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:08:19 +0800 Subject: [PATCH 2/3] feat(service): add XAIExecutor to home executors registration --- sdk/cliproxy/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 2873f002..f9f33e23 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1193,6 +1193,7 @@ func (s *Service) registerHomeExecutors() { s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, "", s.wsGateway)) s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg)) s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewXAIExecutor(s.cfg)) s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("openai-compatibility", s.cfg)) } -- 2.51.2 From ca1f6271f50d1a37153537b4bab68f345fe5136a Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:15:35 +0800 Subject: [PATCH 3/3] feat(executor): refactor executor registration --- sdk/cliproxy/service.go | 150 ++++++++++++------ .../service_executor_registration_test.go | 101 ++++++++++++ .../service_xai_executor_binding_test.go | 36 ----- 3 files changed, 205 insertions(+), 82 deletions(-) create mode 100644 sdk/cliproxy/service_executor_registration_test.go delete mode 100644 sdk/cliproxy/service_xai_executor_binding_test.go diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index f9f33e23..d3cd9a4b 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -121,6 +121,20 @@ type modelRegistrationTask struct { run func() } +type executorRegistrationOptions struct { + includeBaseline bool + includePlugins bool + forceReplaceAuths bool + auths []*coreauth.Auth +} + +var registerPluginExecutors = func(host *pluginhost.Host, manager *coreauth.Manager) { + if host == nil || manager == nil { + return + } + host.RegisterExecutors(manager, registry.GetGlobalRegistry()) +} + // RegisterUsagePlugin registers a usage plugin on the global usage manager. // This allows external code to monitor API usage and token consumption. // @@ -191,8 +205,12 @@ func (s *Service) syncPluginModelRuntime(ctx context.Context) { ctx = context.Background() } s.pluginHost.RegisterModels(ctx, registry.GetGlobalRegistry()) - s.rebindExecutors() - s.pluginHost.RegisterExecutors(s.coreManager, registry.GetGlobalRegistry()) + s.registerAvailableExecutors(ctx, executorRegistrationOptions{ + includeBaseline: s.cfg != nil && s.cfg.Home.Enabled, + includePlugins: true, + forceReplaceAuths: true, + auths: s.coreManager.List(), + }) s.refreshPluginModelRegistrations(ctx) s.coreManager.RefreshSchedulerAll() } @@ -809,6 +827,76 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { } func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace bool) { + if a == nil { + return + } + s.registerAvailableExecutors(context.Background(), executorRegistrationOptions{ + auths: []*coreauth.Auth{a}, + forceReplaceAuths: forceReplace, + }) +} + +func (s *Service) registerAvailableExecutors(ctx context.Context, opts executorRegistrationOptions) { + if s == nil || s.coreManager == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + // Keep all Service-owned executor registration paths here so native, Home, + // auth-derived, and plugin executors stay in the same binding order. + if opts.includeBaseline { + s.registerExecutorsForAuths(baselineExecutorAuths(), true) + } + if len(opts.auths) > 0 { + s.registerExecutorsForAuths(opts.auths, opts.forceReplaceAuths) + } + if opts.includePlugins && s.pluginHost != nil { + registerPluginExecutors(s.pluginHost, s.coreManager) + } +} + +func baselineExecutorAuths() []*coreauth.Auth { + providers := []string{ + "codex", + "claude", + "gemini", + "vertex", + "gemini-cli", + "aistudio", + "antigravity", + "kimi", + "xai", + "openai-compatibility", + } + auths := make([]*coreauth.Auth, 0, len(providers)) + for _, provider := range providers { + auth := &coreauth.Auth{ + ID: provider, + Provider: provider, + } + if provider == "openai-compatibility" { + auth.Attributes = map[string]string{"compat_name": "openai-compatibility"} + } + auths = append(auths, auth) + } + return auths +} + +func (s *Service) registerExecutorsForAuths(auths []*coreauth.Auth, forceReplace bool) { + reboundCodex := false + for _, auth := range auths { + if auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { + if reboundCodex && forceReplace { + continue + } + reboundCodex = true + } + s.registerExecutorForAuth(auth, forceReplace) + } +} + +func (s *Service) registerExecutorForAuth(a *coreauth.Auth, forceReplace bool) { if s == nil || s.coreManager == nil || a == nil { return } @@ -1015,24 +1103,6 @@ func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreaut return true } -// rebindExecutors refreshes provider executors so they observe the latest configuration. -func (s *Service) rebindExecutors() { - if s == nil || s.coreManager == nil { - return - } - auths := s.coreManager.List() - reboundCodex := false - for _, auth := range auths { - if auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { - if reboundCodex { - continue - } - reboundCodex = true - } - s.ensureExecutorsForAuthWithMode(auth, true) - } -} - func (s *Service) applyConfigUpdate(newCfg *config.Config) { if s == nil { return @@ -1117,10 +1187,15 @@ func (s *Service) applyConfigUpdate(newCfg *config.Config) { s.coreManager.SetConfig(newCfg) s.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias) } - if newCfg.Home.Enabled { - s.registerHomeExecutors() + var auths []*coreauth.Auth + if s.coreManager != nil { + auths = s.coreManager.List() } - s.rebindExecutors() + s.registerAvailableExecutors(context.Background(), executorRegistrationOptions{ + includeBaseline: newCfg.Home.Enabled, + forceReplaceAuths: true, + auths: auths, + }) ctx := context.Background() s.registerConfigAPIKeyAuths(ctx, newCfg) s.syncPluginRuntime(ctx) @@ -1178,25 +1253,6 @@ func forceHomeRuntimeConfig(cfg *config.Config) { cfg.RemoteManagement.DisableControlPanel = true } -func (s *Service) registerHomeExecutors() { - if s == nil || s.coreManager == nil || s.cfg == nil { - return - } - - // Register baseline executors so home-dispatched auth entries can execute without - // requiring any local auth-dir credentials. - s.coreManager.RegisterExecutor(executor.NewCodexAutoExecutor(s.cfg)) - s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg)) - s.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg)) - s.coreManager.RegisterExecutor(executor.NewGeminiVertexExecutor(s.cfg)) - s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg)) - s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, "", s.wsGateway)) - s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg)) - s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg)) - s.coreManager.RegisterExecutor(executor.NewXAIExecutor(s.cfg)) - s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("openai-compatibility", s.cfg)) -} - func (s *Service) applyHomeOverlay(remoteCfg *config.Config) { if s == nil || remoteCfg == nil { return @@ -1417,7 +1473,9 @@ func (s *Service) Run(ctx context.Context) error { s.ensureWebsocketGateway() if homeEnabled { - s.registerHomeExecutors() + s.registerAvailableExecutors(ctx, executorRegistrationOptions{ + includeBaseline: true, + }) // Home mode does not expose in-process Redis RESP usage output; usage is forwarded to home instead. redisqueue.SetEnabled(true) } @@ -1610,9 +1668,9 @@ func (s *Service) Shutdown(ctx context.Context) error { } s.pluginHost.ApplyConfig(ctx, &config.Config{}) s.pluginHost.RegisterModels(ctx, registry.GetGlobalRegistry()) - if s.coreManager != nil { - s.pluginHost.RegisterExecutors(s.coreManager, registry.GetGlobalRegistry()) - } + s.registerAvailableExecutors(ctx, executorRegistrationOptions{ + includePlugins: true, + }) s.pluginHost.RegisterFrontendAuthProviders() s.pluginHost.ShutdownAll() if s.accessManager != nil { diff --git a/sdk/cliproxy/service_executor_registration_test.go b/sdk/cliproxy/service_executor_registration_test.go new file mode 100644 index 00000000..d3867987 --- /dev/null +++ b/sdk/cliproxy/service_executor_registration_test.go @@ -0,0 +1,101 @@ +package cliproxy + +import ( + "context" + "net/http" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" +) + +type serviceTestPluginExecutor struct{} + +func (serviceTestPluginExecutor) Identifier() string { + return "plugin-provider" +} + +func (serviceTestPluginExecutor) Execute(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, nil +} + +func (serviceTestPluginExecutor) ExecuteStream(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + return nil, nil +} + +func (serviceTestPluginExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { + return auth, nil +} + +func (serviceTestPluginExecutor) CountTokens(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, nil +} + +func (serviceTestPluginExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) { + return nil, nil +} + +func TestRegisterAvailableExecutors(t *testing.T) { + oldRegisterPluginExecutors := registerPluginExecutors + pluginRegisterCalls := 0 + var expectedPluginHost *pluginhost.Host + var expectedManager *coreauth.Manager + registerPluginExecutors = func(host *pluginhost.Host, manager *coreauth.Manager) { + pluginRegisterCalls++ + if host != expectedPluginHost { + t.Fatalf("plugin executor registration host = %p, want %p", host, expectedPluginHost) + } + if manager != expectedManager { + t.Fatalf("plugin executor registration manager = %p, want %p", manager, expectedManager) + } + manager.RegisterExecutor(serviceTestPluginExecutor{}) + } + t.Cleanup(func() { + registerPluginExecutors = oldRegisterPluginExecutors + }) + + service := &Service{ + cfg: &config.Config{}, + coreManager: coreauth.NewManager(nil, nil, nil), + pluginHost: pluginhost.New(), + } + expectedPluginHost = service.pluginHost + expectedManager = service.coreManager + service.ensureWebsocketGateway() + + service.registerAvailableExecutors(nil, executorRegistrationOptions{ + includeBaseline: true, + includePlugins: true, + }) + + if pluginRegisterCalls != 1 { + t.Fatalf("plugin executor registration calls = %d, want 1", pluginRegisterCalls) + } + + providers := []string{ + "codex", + "claude", + "gemini", + "vertex", + "gemini-cli", + "aistudio", + "antigravity", + "kimi", + "xai", + "openai-compatibility", + "plugin-provider", + } + for _, provider := range providers { + resolved, ok := service.coreManager.Executor(provider) + if !ok || resolved == nil { + t.Fatalf("expected executor for provider %s after registration", provider) + } + } + + resolved, _ := service.coreManager.Executor("plugin-provider") + if _, isPlugin := resolved.(serviceTestPluginExecutor); !isPlugin { + t.Fatalf("executor type = %T, want serviceTestPluginExecutor", resolved) + } +} diff --git a/sdk/cliproxy/service_xai_executor_binding_test.go b/sdk/cliproxy/service_xai_executor_binding_test.go deleted file mode 100644 index 0329b976..00000000 --- a/sdk/cliproxy/service_xai_executor_binding_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package cliproxy - -import ( - "testing" - - "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" - coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" - "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" -) - -func TestEnsureExecutorsForAuth_XAIBindsIndependentExecutor(t *testing.T) { - service := &Service{ - cfg: &config.Config{}, - coreManager: coreauth.NewManager(nil, nil, nil), - } - auth := &coreauth.Auth{ - ID: "xai-auth-1", - Provider: "xai", - Status: coreauth.StatusActive, - Attributes: map[string]string{ - "auth_kind": "oauth", - }, - } - - service.ensureExecutorsForAuth(auth) - resolved, ok := service.coreManager.Executor("xai") - if !ok || resolved == nil { - t.Fatal("expected xai executor after bind") - } - if _, isXAI := resolved.(*executor.XAIExecutor); !isXAI { - t.Fatalf("executor type = %T, want *executor.XAIExecutor", resolved) - } - if _, isCodex := resolved.(*executor.CodexAutoExecutor); isCodex { - t.Fatal("xai must not bind the codex auto executor") - } -}