mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-24 22:09:14 +08:00
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:
@@ -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}
|
||||
|
||||
|
||||
62
internal/managementasset/updater_test.go
Normal file
62
internal/managementasset/updater_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
101
sdk/cliproxy/service_executor_registration_test.go
Normal file
101
sdk/cliproxy/service_executor_registration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user