Merge pull request #3795 from router-for-me/webui

feat(auto-updater): refactor skip logic and add unit tests for autoUpdateSkipReason
This commit is contained in:
Luis Pater
2026-06-11 10:31:58 +08:00
committed by GitHub
5 changed files with 285 additions and 91 deletions

View File

@@ -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}

View File

@@ -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)
}
})
}
}

View File

@@ -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,24 +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.NewOpenAICompatExecutor("openai-compatibility", s.cfg))
}
func (s *Service) applyHomeOverlay(remoteCfg *config.Config) {
if s == nil || remoteCfg == nil {
return
@@ -1416,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)
}
@@ -1609,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 {

View File

@@ -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)
}
}

View File

@@ -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")
}
}