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
This commit is contained in:
Luis Pater
2026-08-08 06:08:23 +08:00
parent e64cdbf559
commit 01a21b77f4
7 changed files with 708 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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