From 01a21b77f4dc126b92809e746cfd4ed251e69e13 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 8 Aug 2026 06:08:23 +0800 Subject: [PATCH] fix(cliproxy): delegate OpenAI-compatible OAuth refresh to plugin auth providers - Added a plugin refresh-compat executor wrapper that forwards normal OpenAI-compat execution paths while routing `Refresh` to plugin `AuthProvider`/Home refresh logic. - Updated refresh lookup to use the effective executor key from auth metadata so namespaced compatibility providers can resolve their refresh executors correctly. - Changed OpenAI-compat registration to wrap built-in executors with the plugin-refresh wrapper when a matching plugin auth provider exists, while preserving bare executors otherwise. - Made `OpenAICompatExecutor.Refresh` fail fast for OAuth-style credentials (with refresh tokens) instead of silently returning unchanged auth. Closes: #4719 --- .../plugin_refresh_compat_executor.go | 154 +++++++++++++++ .../plugin_refresh_compat_executor_test.go | 176 ++++++++++++++++++ .../executor/openai_compat_executor.go | 25 +++ sdk/cliproxy/auth/conductor_refresh.go | 4 +- .../conductor_refresh_executor_key_test.go | 77 ++++++++ sdk/cliproxy/service_executors.go | 123 ++++++++++-- .../service_plugin_refresh_executor_test.go | 164 ++++++++++++++++ 7 files changed, 708 insertions(+), 15 deletions(-) create mode 100644 internal/pluginhost/plugin_refresh_compat_executor.go create mode 100644 internal/pluginhost/plugin_refresh_compat_executor_test.go create mode 100644 sdk/cliproxy/auth/conductor_refresh_executor_key_test.go create mode 100644 sdk/cliproxy/service_plugin_refresh_executor_test.go diff --git a/internal/pluginhost/plugin_refresh_compat_executor.go b/internal/pluginhost/plugin_refresh_compat_executor.go new file mode 100644 index 000000000..b5296c68e --- /dev/null +++ b/internal/pluginhost/plugin_refresh_compat_executor.go @@ -0,0 +1,154 @@ +package pluginhost + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +// pluginRefreshCompatExecutor keeps native OpenAI-compat inference while +// routing credential refresh to a plugin AuthProvider. +// +// Plugins often set Attributes["base_url"] so host routing uses the built-in +// OpenAI-compat executor. That binding previously swallowed refresh because +// OpenAICompatExecutor.Refresh is a no-op for non-Home providers. This wrapper +// preserves native Execute* paths and delegates Refresh to Host.RefreshAuth. +type pluginRefreshCompatExecutor struct { + inner coreauth.ProviderExecutor + host *Host + cfg *config.Config + provider string +} + +// NewPluginRefreshCompatExecutor wraps a native provider executor so Refresh is +// handled by the plugin AuthProvider for the same provider key. +func NewPluginRefreshCompatExecutor(inner coreauth.ProviderExecutor, host *Host, cfg *config.Config) coreauth.ProviderExecutor { + if inner == nil { + return nil + } + provider := strings.ToLower(strings.TrimSpace(inner.Identifier())) + return &pluginRefreshCompatExecutor{ + inner: inner, + host: host, + cfg: cfg, + provider: provider, + } +} + +// IsPluginRefreshCompatExecutor reports whether executor is a plugin-refresh wrapper. +func IsPluginRefreshCompatExecutor(executor coreauth.ProviderExecutor) bool { + _, ok := executor.(*pluginRefreshCompatExecutor) + return ok +} + +// UnwrapPluginRefreshCompatExecutor returns the inner native executor when executor +// is a plugin-refresh wrapper. +func UnwrapPluginRefreshCompatExecutor(executor coreauth.ProviderExecutor) (coreauth.ProviderExecutor, bool) { + wrapper, ok := executor.(*pluginRefreshCompatExecutor) + if !ok || wrapper == nil || wrapper.inner == nil { + return nil, false + } + return wrapper.inner, true +} + +func (e *pluginRefreshCompatExecutor) Identifier() string { + if e == nil { + return "" + } + if e.provider != "" { + return e.provider + } + if e.inner != nil { + return e.inner.Identifier() + } + return "" +} + +func (e *pluginRefreshCompatExecutor) Execute(ctx context.Context, auth *coreauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + if e == nil || e.inner == nil { + return cliproxyexecutor.Response{}, fmt.Errorf("plugin refresh compat executor is unavailable") + } + return e.inner.Execute(ctx, auth, req, opts) +} + +func (e *pluginRefreshCompatExecutor) ExecuteStream(ctx context.Context, auth *coreauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + if e == nil || e.inner == nil { + return nil, fmt.Errorf("plugin refresh compat executor is unavailable") + } + return e.inner.ExecuteStream(ctx, auth, req, opts) +} + +func (e *pluginRefreshCompatExecutor) CountTokens(ctx context.Context, auth *coreauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + if e == nil || e.inner == nil { + return cliproxyexecutor.Response{}, fmt.Errorf("plugin refresh compat executor is unavailable") + } + return e.inner.CountTokens(ctx, auth, req, opts) +} + +func (e *pluginRefreshCompatExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) { + if e == nil || e.inner == nil { + return nil, fmt.Errorf("plugin refresh compat executor is unavailable") + } + return e.inner.HttpRequest(ctx, auth, req) +} + +// PrepareRequest forwards credential injection to the inner executor when supported. +func (e *pluginRefreshCompatExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error { + if e == nil || e.inner == nil { + return fmt.Errorf("plugin refresh compat executor is unavailable") + } + preparer, ok := e.inner.(interface { + PrepareRequest(*http.Request, *coreauth.Auth) error + }) + if !ok || preparer == nil { + return nil + } + return preparer.PrepareRequest(req, auth) +} + +func (e *pluginRefreshCompatExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { + if e == nil { + return nil, fmt.Errorf("plugin refresh compat executor is unavailable") + } + if ctx == nil { + ctx = context.Background() + } + if refreshed, handled, errHome := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { + return refreshed, errHome + } + if e.host != nil { + if refreshed, handled, errRefresh := e.host.RefreshAuth(ctx, auth); handled { + return refreshed, errRefresh + } + } + if authHasRefreshToken(auth) { + provider := e.Identifier() + if provider == "" && auth != nil { + provider = strings.TrimSpace(auth.Provider) + } + return nil, fmt.Errorf("plugin auth provider refresh is unavailable for provider %s", provider) + } + if auth == nil { + return nil, nil + } + return auth.Clone(), nil +} + +func authHasRefreshToken(auth *coreauth.Auth) bool { + if auth == nil || auth.Metadata == nil { + return false + } + if token, _ := auth.Metadata["refresh_token"].(string); strings.TrimSpace(token) != "" { + return true + } + if token, _ := auth.Metadata["refreshToken"].(string); strings.TrimSpace(token) != "" { + return true + } + return false +} diff --git a/internal/pluginhost/plugin_refresh_compat_executor_test.go b/internal/pluginhost/plugin_refresh_compat_executor_test.go new file mode 100644 index 000000000..2e6fe3b97 --- /dev/null +++ b/internal/pluginhost/plugin_refresh_compat_executor_test.go @@ -0,0 +1,176 @@ +package pluginhost + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + 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/pluginapi" +) + +type stubCompatExecutor struct { + id string + executeCalls int + refreshCalls int +} + +func (e *stubCompatExecutor) Identifier() string { return e.id } + +func (e *stubCompatExecutor) Execute(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + e.executeCalls++ + return cliproxyexecutor.Response{Payload: []byte(`{"ok":true}`)}, nil +} + +func (e *stubCompatExecutor) ExecuteStream(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + return &cliproxyexecutor.StreamResult{}, nil +} + +func (e *stubCompatExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { + e.refreshCalls++ + return auth, nil +} + +func (e *stubCompatExecutor) CountTokens(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, nil +} + +func (e *stubCompatExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) { + return nil, nil +} + +func (e *stubCompatExecutor) PrepareRequest(*http.Request, *coreauth.Auth) error { + return nil +} + +func TestPluginRefreshCompatExecutorDelegatesExecuteAndRefresh(t *testing.T) { + refreshCalls := 0 + host := newHostWithRecords(capabilityRecord{ + id: "auth-plugin", + plugin: pluginapi.Plugin{ + Capabilities: pluginapi.Capabilities{ + AuthProvider: fakeAuthProvider{ + identifier: "plugin-provider", + refreshAuth: func(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) { + refreshCalls++ + if req.AuthID != "auth-1" || req.AuthProvider != "plugin-provider" { + t.Fatalf("RefreshAuth request = %#v", req) + } + return pluginapi.AuthRefreshResponse{ + Auth: pluginapi.AuthData{ + ID: "auth-1", + Provider: "plugin-provider", + Metadata: map[string]any{ + "access_token": "new-token", + "refresh_token": "refresh-1", + }, + Attributes: map[string]string{ + "base_url": "https://compat.example.com/v1", + }, + }, + }, nil + }, + }, + }, + }, + }) + + inner := &stubCompatExecutor{id: "plugin-provider"} + wrapped := NewPluginRefreshCompatExecutor(inner, host, &config.Config{}) + if wrapped == nil { + t.Fatal("NewPluginRefreshCompatExecutor() = nil") + } + if !IsPluginRefreshCompatExecutor(wrapped) { + t.Fatal("IsPluginRefreshCompatExecutor() = false, want true") + } + if got, ok := UnwrapPluginRefreshCompatExecutor(wrapped); !ok || got != inner { + t.Fatalf("UnwrapPluginRefreshCompatExecutor() = (%T, %v), want inner", got, ok) + } + if wrapped.Identifier() != "plugin-provider" { + t.Fatalf("Identifier() = %q, want plugin-provider", wrapped.Identifier()) + } + + auth := &coreauth.Auth{ + ID: "auth-1", + Provider: "plugin-provider", + Metadata: map[string]any{ + "access_token": "old-token", + "refresh_token": "refresh-1", + }, + Attributes: map[string]string{ + "base_url": "https://compat.example.com/v1", + }, + } + + if _, errExecute := wrapped.Execute(context.Background(), auth, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if inner.executeCalls != 1 { + t.Fatalf("inner Execute calls = %d, want 1", inner.executeCalls) + } + + refreshed, errRefresh := wrapped.Refresh(context.Background(), auth) + if errRefresh != nil { + t.Fatalf("Refresh() error = %v", errRefresh) + } + if refreshCalls != 1 { + t.Fatalf("plugin RefreshAuth calls = %d, want 1", refreshCalls) + } + if inner.refreshCalls != 0 { + t.Fatalf("inner Refresh calls = %d, want 0", inner.refreshCalls) + } + if refreshed == nil || refreshed.Metadata["access_token"] != "new-token" { + t.Fatalf("Refresh() auth = %#v, want updated access_token", refreshed) + } + if refreshed.Attributes["base_url"] != "https://compat.example.com/v1" { + t.Fatalf("Refresh() base_url = %q, want preserved", refreshed.Attributes["base_url"]) + } +} + +func TestPluginRefreshCompatExecutorErrorsWhenRefreshUnavailable(t *testing.T) { + inner := &stubCompatExecutor{id: "plugin-provider"} + wrapped := NewPluginRefreshCompatExecutor(inner, New(), &config.Config{}) + auth := &coreauth.Auth{ + ID: "auth-1", + Provider: "plugin-provider", + Metadata: map[string]any{ + "access_token": "old-token", + "refresh_token": "refresh-1", + }, + } + + _, errRefresh := wrapped.Refresh(context.Background(), auth) + if errRefresh == nil { + t.Fatal("Refresh() error = nil, want unavailable plugin refresh error") + } + if !strings.Contains(errRefresh.Error(), "plugin auth provider refresh is unavailable") { + t.Fatalf("Refresh() error = %v, want unavailable message", errRefresh) + } + if inner.refreshCalls != 0 { + t.Fatalf("inner Refresh calls = %d, want 0", inner.refreshCalls) + } +} + +func TestPluginRefreshCompatExecutorNoOpForAPIKeyAuth(t *testing.T) { + inner := &stubCompatExecutor{id: "plugin-provider"} + wrapped := NewPluginRefreshCompatExecutor(inner, New(), &config.Config{}) + auth := &coreauth.Auth{ + ID: "auth-1", + Provider: "plugin-provider", + Attributes: map[string]string{ + "api_key": "sk-test", + "base_url": "https://compat.example.com/v1", + }, + } + + refreshed, errRefresh := wrapped.Refresh(context.Background(), auth) + if errRefresh != nil { + t.Fatalf("Refresh() error = %v", errRefresh) + } + if refreshed == nil || refreshed.Attributes["api_key"] != "sk-test" { + t.Fatalf("Refresh() auth = %#v, want unchanged api key auth", refreshed) + } +} diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index f425eeacb..1b6a038af 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -643,14 +643,39 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau } // Refresh is a no-op for API-key based compatibility providers. +// OAuth-style credentials with a refresh token cannot be rotated here; callers +// that need plugin/Home refresh must bind a refresh-capable executor instead. func (e *OpenAICompatExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("openai compat executor: refresh called") if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { return refreshed, err } + if openAICompatAuthHasRefreshToken(auth) { + provider := "" + if e != nil { + provider = e.Identifier() + } + if provider == "" && auth != nil { + provider = strings.TrimSpace(auth.Provider) + } + return nil, fmt.Errorf("openai compat executor cannot refresh oauth credentials for provider %s", provider) + } return auth, nil } +func openAICompatAuthHasRefreshToken(auth *cliproxyauth.Auth) bool { + if auth == nil || auth.Metadata == nil { + return false + } + if token, _ := auth.Metadata["refresh_token"].(string); strings.TrimSpace(token) != "" { + return true + } + if token, _ := auth.Metadata["refreshToken"].(string); strings.TrimSpace(token) != "" { + return true + } + return false +} + func openAICompatImageEndpointPath(opts cliproxyexecutor.Options) string { if opts.SourceFormat.String() != openAICompatImageHandlerType { return "" diff --git a/sdk/cliproxy/auth/conductor_refresh.go b/sdk/cliproxy/auth/conductor_refresh.go index 045e88fd9..06a2a27a1 100644 --- a/sdk/cliproxy/auth/conductor_refresh.go +++ b/sdk/cliproxy/auth/conductor_refresh.go @@ -512,7 +512,9 @@ func (m *Manager) refreshAuthForRequest(ctx context.Context, id, failedAccessTok auth := m.auths[id] var exec ProviderExecutor if auth != nil { - exec = m.executors[auth.Provider] + // Use the same effective provider key as request execution so OpenAI-compat + // auths registered under namespaced keys still resolve for refresh. + exec = m.executors[executorKeyFromAuth(auth)] } m.mu.RUnlock() if auth == nil || exec == nil { diff --git a/sdk/cliproxy/auth/conductor_refresh_executor_key_test.go b/sdk/cliproxy/auth/conductor_refresh_executor_key_test.go new file mode 100644 index 000000000..e32399012 --- /dev/null +++ b/sdk/cliproxy/auth/conductor_refresh_executor_key_test.go @@ -0,0 +1,77 @@ +package auth + +import ( + "context" + "net/http" + "sync/atomic" + "testing" + + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +type countingRefreshExecutor struct { + id string + refreshCalls atomic.Int32 +} + +func (e *countingRefreshExecutor) Identifier() string { return e.id } + +func (e *countingRefreshExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, nil +} + +func (e *countingRefreshExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + return nil, nil +} + +func (e *countingRefreshExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) { + e.refreshCalls.Add(1) + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["access_token"] = "refreshed-token" + return auth, nil +} + +func (e *countingRefreshExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, nil +} + +func (e *countingRefreshExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) { + return nil, nil +} + +func TestRefreshAuthForRequest_UsesExecutorKeyFromAuth(t *testing.T) { + ctx := context.Background() + manager := NewManager(nil, &RoundRobinSelector{}, nil) + executor := &countingRefreshExecutor{id: "openai-compatible-custom"} + manager.RegisterExecutor(executor) + + auth := &Auth{ + ID: "compat-oauth", + Provider: "plugin-provider", + Attributes: map[string]string{ + "compat_name": "custom", + "provider_key": "custom", + "base_url": "https://compat.example.com/v1", + }, + Metadata: map[string]any{ + "access_token": "old-token", + "refresh_token": "refresh-1", + }, + } + if _, errRegister := manager.Register(ctx, auth); errRegister != nil { + t.Fatalf("register auth: %v", errRegister) + } + + refreshed, errRefresh := manager.refreshAuthForRequest(ctx, auth.ID, "old-token") + if errRefresh != nil { + t.Fatalf("refreshAuthForRequest() error = %v", errRefresh) + } + if executor.refreshCalls.Load() != 1 { + t.Fatalf("refresh calls = %d, want 1", executor.refreshCalls.Load()) + } + if refreshed == nil || refreshed.Metadata["access_token"] != "refreshed-token" { + t.Fatalf("refreshed auth = %#v, want updated access_token", refreshed) + } +} diff --git a/sdk/cliproxy/service_executors.go b/sdk/cliproxy/service_executors.go index 371ed6a92..0213ee4f4 100644 --- a/sdk/cliproxy/service_executors.go +++ b/sdk/cliproxy/service_executors.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -17,6 +18,11 @@ type openAICompatibilityRegistrationCache struct { byIndex map[int]*openAICompatibilityRegistrationEntry } +// pluginHostHasAuthProvider is overridable in tests to avoid loading real plugins. +var pluginHostHasAuthProvider = func(host *pluginhost.Host, provider string) bool { + return host != nil && host.HasAuthProvider(provider) +} + type openAICompatibilityRegistrationEntry struct { providerKey string models []*ModelInfo @@ -140,10 +146,13 @@ func (s *Service) unregisterOpenAICompatExecutor(providerKey string) { if !okExecutor || existing == nil { return } - if _, okOpenAICompat := existing.(*executor.OpenAICompatExecutor); !okOpenAICompat { + if _, okOpenAICompat := existing.(*executor.OpenAICompatExecutor); okOpenAICompat { + s.coreManager.UnregisterExecutor(providerKey) return } - s.coreManager.UnregisterExecutor(providerKey) + if pluginhost.IsPluginRefreshCompatExecutor(existing) { + s.coreManager.UnregisterExecutor(providerKey) + } } func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { @@ -262,14 +271,7 @@ func (s *Service) registerExecutorForAuth(a *coreauth.Auth, forceReplace bool) { if compatProviderKey == "" { compatProviderKey = "openai-compatibility" } - if !forceReplace { - if existingExecutor, hasExecutor := s.coreManager.Executor(compatProviderKey); hasExecutor { - if _, isOpenAICompatExecutor := existingExecutor.(*executor.OpenAICompatExecutor); isOpenAICompatExecutor { - return - } - } - } - s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(compatProviderKey, cfg)) + s.registerOpenAICompatProviderExecutor(compatProviderKey, a, cfg, forceReplace, false) return } switch strings.ToLower(a.Provider) { @@ -312,14 +314,107 @@ func (s *Service) registerExecutorForAuth(a *coreauth.Auth, forceReplace bool) { s.unregisterOpenAICompatExecutor(providerKey) return } - if !forceReplace { - if existingExecutor, hasExecutor := s.coreManager.Executor(providerKey); hasExecutor && - (s.pluginHost == nil || !s.pluginHost.OwnsExecutor(existingExecutor)) { + // Keep native OpenAI-compat inference for base_url routing, but delegate + // OAuth refresh to the plugin AuthProvider when one is registered. + s.registerOpenAICompatProviderExecutor(providerKey, a, cfg, forceReplace, true) + } +} + +// registerOpenAICompatProviderExecutor binds a native OpenAI-compat executor, optionally +// wrapping it so plugin AuthProvider refresh remains available. +// When respectNonOwned is true, an existing non-owned executor is preserved unless it is a +// bare OpenAI-compat executor that should be upgraded to the plugin-refresh wrapper. +func (s *Service) registerOpenAICompatProviderExecutor(providerKey string, auth *coreauth.Auth, cfg *config.Config, forceReplace bool, respectNonOwned bool) { + if s == nil || s.coreManager == nil { + return + } + providerKey = strings.ToLower(strings.TrimSpace(providerKey)) + if providerKey == "" { + providerKey = "openai-compatibility" + } + compatExecutor := executor.NewOpenAICompatExecutor(providerKey, cfg) + nextExecutor := s.wrapOpenAICompatIfPluginAuth(compatExecutor, auth, cfg) + if !forceReplace { + if existingExecutor, hasExecutor := s.coreManager.Executor(providerKey); hasExecutor { + if shouldKeepExistingOpenAICompatExecutor(s, existingExecutor, nextExecutor, respectNonOwned) { return } } - s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor(providerKey, cfg)) } + s.coreManager.RegisterExecutor(nextExecutor) +} + +func (s *Service) wrapOpenAICompatIfPluginAuth(compatExecutor *executor.OpenAICompatExecutor, auth *coreauth.Auth, cfg *config.Config) coreauth.ProviderExecutor { + if compatExecutor == nil { + return nil + } + for _, candidate := range pluginAuthProviderLookupKeys(auth, compatExecutor.Identifier()) { + if pluginHostHasAuthProvider(s.pluginHost, candidate) { + return pluginhost.NewPluginRefreshCompatExecutor(compatExecutor, s.pluginHost, cfg) + } + } + return compatExecutor +} + +func pluginAuthProviderLookupKeys(auth *coreauth.Auth, fallback string) []string { + keys := make([]string, 0, 4) + add := func(value string) { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return + } + for _, existing := range keys { + if existing == value { + return + } + } + keys = append(keys, value) + } + if auth != nil { + add(auth.Provider) + if auth.Attributes != nil { + add(auth.Attributes["provider_key"]) + add(auth.Attributes["compat_name"]) + } + } + add(fallback) + return keys +} + +func shouldKeepExistingOpenAICompatExecutor(s *Service, existing, next coreauth.ProviderExecutor, respectNonOwned bool) bool { + if existing == nil || next == nil { + return false + } + if shouldUpgradeOpenAICompatToPluginRefresh(existing, next) { + return false + } + if pluginhost.IsPluginRefreshCompatExecutor(existing) && pluginhost.IsPluginRefreshCompatExecutor(next) { + return true + } + _, existingBare := existing.(*executor.OpenAICompatExecutor) + _, nextBare := next.(*executor.OpenAICompatExecutor) + if existingBare && nextBare { + return true + } + if !respectNonOwned { + // Historical openai-compatibility path only short-circuits bare native executors. + return existingBare + } + if s != nil && s.pluginHost != nil && s.pluginHost.OwnsExecutor(existing) { + return false + } + return true +} + +func shouldUpgradeOpenAICompatToPluginRefresh(existing, next coreauth.ProviderExecutor) bool { + if existing == nil || next == nil { + return false + } + if !pluginhost.IsPluginRefreshCompatExecutor(next) { + return false + } + _, bareOpenAICompat := existing.(*executor.OpenAICompatExecutor) + return bareOpenAICompat } func (s *Service) registerResolvedModelsForAuth(a *coreauth.Auth, providerKey string, models []*ModelInfo) { diff --git a/sdk/cliproxy/service_plugin_refresh_executor_test.go b/sdk/cliproxy/service_plugin_refresh_executor_test.go new file mode 100644 index 000000000..33c478580 --- /dev/null +++ b/sdk/cliproxy/service_plugin_refresh_executor_test.go @@ -0,0 +1,164 @@ +package cliproxy + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" + runtimeexecutor "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 TestRegisterExecutorForAuth_PluginAuthProviderWrapsOpenAICompatRefresh(t *testing.T) { + oldHasAuthProvider := pluginHostHasAuthProvider + pluginHostHasAuthProvider = func(host *pluginhost.Host, provider string) bool { + return host != nil && provider == "plugin-provider" + } + t.Cleanup(func() { + pluginHostHasAuthProvider = oldHasAuthProvider + }) + + service := &Service{ + cfg: &config.Config{}, + coreManager: coreauth.NewManager(nil, nil, nil), + pluginHost: pluginhost.New(), + } + + auth := &coreauth.Auth{ + ID: "plugin-auth-1", + Provider: "plugin-provider", + Attributes: map[string]string{ + "base_url": "https://compat.example.com/v1", + "api_key": "expired-token", + }, + Metadata: map[string]any{ + "access_token": "expired-token", + "refresh_token": "refresh-1", + }, + } + + service.registerExecutorForAuth(auth, true) + + resolved, ok := service.coreManager.Executor("plugin-provider") + if !ok || resolved == nil { + t.Fatal("expected executor for plugin-provider") + } + if !pluginhost.IsPluginRefreshCompatExecutor(resolved) { + t.Fatalf("executor type = %T, want plugin refresh compat wrapper", resolved) + } + inner, okInner := pluginhost.UnwrapPluginRefreshCompatExecutor(resolved) + if !okInner { + t.Fatal("expected unwrap of plugin refresh compat executor") + } + if _, okOpenAICompat := inner.(*runtimeexecutor.OpenAICompatExecutor); !okOpenAICompat { + t.Fatalf("inner executor type = %T, want *executor.OpenAICompatExecutor", inner) + } + + // Upgrading from bare OpenAICompat without forceReplace should still wrap. + service.coreManager.RegisterExecutor(runtimeexecutor.NewOpenAICompatExecutor("plugin-provider", service.cfg)) + service.registerExecutorForAuth(auth, false) + resolved, ok = service.coreManager.Executor("plugin-provider") + if !ok || !pluginhost.IsPluginRefreshCompatExecutor(resolved) { + t.Fatalf("upgrade path executor type = %T, want plugin refresh compat wrapper", resolved) + } +} + +func TestRegisterExecutorForAuth_OpenAICompatWithoutPluginAuthProviderStaysBare(t *testing.T) { + service := &Service{ + cfg: &config.Config{}, + coreManager: coreauth.NewManager(nil, nil, nil), + pluginHost: pluginhost.New(), + } + auth := &coreauth.Auth{ + ID: "compat-auth-1", + Provider: "custom-compat", + Attributes: map[string]string{ + "base_url": "https://compat.example.com/v1", + "api_key": "sk-test", + }, + } + + service.registerExecutorForAuth(auth, true) + + resolved, ok := service.coreManager.Executor("custom-compat") + if !ok || resolved == nil { + t.Fatal("expected executor for custom-compat") + } + if pluginhost.IsPluginRefreshCompatExecutor(resolved) { + t.Fatal("did not expect plugin refresh wrapper without AuthProvider") + } + if _, okOpenAICompat := resolved.(*runtimeexecutor.OpenAICompatExecutor); !okOpenAICompat { + t.Fatalf("executor type = %T, want *executor.OpenAICompatExecutor", resolved) + } +} + +func TestRegisterExecutorForAuth_OpenAICompatInfoPathAlsoWrapsPluginRefresh(t *testing.T) { + oldHasAuthProvider := pluginHostHasAuthProvider + pluginHostHasAuthProvider = func(host *pluginhost.Host, provider string) bool { + return host != nil && provider == "plugin-provider" + } + t.Cleanup(func() { + pluginHostHasAuthProvider = oldHasAuthProvider + }) + + service := &Service{ + cfg: &config.Config{}, + coreManager: coreauth.NewManager(nil, nil, nil), + pluginHost: pluginhost.New(), + } + auth := &coreauth.Auth{ + ID: "plugin-auth-compat", + Provider: "plugin-provider", + Attributes: map[string]string{ + "base_url": "https://compat.example.com/v1", + "compat_name": "custom", + "provider_key": "custom", + }, + Metadata: map[string]any{ + "access_token": "expired-token", + "refresh_token": "refresh-1", + }, + } + + service.registerExecutorForAuth(auth, true) + + resolved, ok := service.coreManager.Executor("openai-compatible-custom") + if !ok || resolved == nil { + t.Fatal("expected executor for openai-compatible-custom") + } + if !pluginhost.IsPluginRefreshCompatExecutor(resolved) { + t.Fatalf("executor type = %T, want plugin refresh compat wrapper", resolved) + } +} + +func TestUnregisterOpenAICompatExecutorRemovesPluginRefreshWrapper(t *testing.T) { + oldHasAuthProvider := pluginHostHasAuthProvider + pluginHostHasAuthProvider = func(host *pluginhost.Host, provider string) bool { + return host != nil && provider == "plugin-provider" + } + t.Cleanup(func() { + pluginHostHasAuthProvider = oldHasAuthProvider + }) + + service := &Service{ + cfg: &config.Config{}, + coreManager: coreauth.NewManager(nil, nil, nil), + pluginHost: pluginhost.New(), + } + auth := &coreauth.Auth{ + ID: "plugin-auth-1", + Provider: "plugin-provider", + Attributes: map[string]string{ + "base_url": "https://compat.example.com/v1", + }, + } + service.registerExecutorForAuth(auth, true) + if _, ok := service.coreManager.Executor("plugin-provider"); !ok { + t.Fatal("expected wrapper before unregister") + } + + service.unregisterOpenAICompatExecutor("plugin-provider") + if _, ok := service.coreManager.Executor("plugin-provider"); ok { + t.Fatal("expected plugin-provider executor to be removed") + } +}