mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
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:
154
internal/pluginhost/plugin_refresh_compat_executor.go
Normal file
154
internal/pluginhost/plugin_refresh_compat_executor.go
Normal 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
|
||||
}
|
||||
176
internal/pluginhost/plugin_refresh_compat_executor_test.go
Normal file
176
internal/pluginhost/plugin_refresh_compat_executor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
77
sdk/cliproxy/auth/conductor_refresh_executor_key_test.go
Normal file
77
sdk/cliproxy/auth/conductor_refresh_executor_key_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
164
sdk/cliproxy/service_plugin_refresh_executor_test.go
Normal file
164
sdk/cliproxy/service_plugin_refresh_executor_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user