Files
CLIProxyAPI/internal/pluginhost/host.go
hkfires adc436ff87 fix(pluginhost): bind catalog-only plugin models to credentials
Stop RegisterModels from publishing a synthetic plugin client for
plugins without an executor, which advertised their models without
any routable credential and bypassed per-auth model exclusions.

Retain the static catalog only for executor arbitration and
per-auth registration, so public availability now follows active
credentials and their exclusions. Reserve native provider models
before plugin arbitration to preserve native priority, and remove
the now unused model client commit path.
2026-10-18 16:12:11 +08:00

1228 lines
33 KiB
Go

package pluginhost
import (
"bytes"
"context"
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type loadedPlugin struct {
id string
path string
version string
name string
configYAML []byte
plugin pluginapi.Plugin
registered bool
client pluginClient
callbackInstance *hostCallbackInstance
}
type modelExecutor interface {
ExecuteModel(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionResponse, *interfaces.ErrorMessage)
ExecuteModelStream(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage)
}
type pluginUnloadTarget struct {
id string
name string
path string
version string
client pluginClient
callbackInstance *hostCallbackInstance
}
type pluginLoadRequest struct {
result chan pluginLoadResult
cleanupStarted bool
closed bool // Protected by Host.mu; rejects callbacks arriving after cleanup starts.
callbackInstances map[*hostCallbackInstance]struct{}
}
type pluginLoadResult struct {
loaded *loadedPlugin
plugin pluginapi.Plugin
initialized bool
err error
}
type Host struct {
applyMu chan struct{}
mu sync.Mutex
loader pluginLoader
loaded map[string]*loadedPlugin
retired map[string][]*loadedPlugin
loading map[string]*pluginLoadRequest
fused map[string]string
pluginFileVersions map[string]string
activePluginVersions map[string]string
activePluginPaths map[string]string
cleanupFilesPending bool
runtimeConfig *config.Config
authManager *coreauth.Manager
modelExecutor modelExecutor
modelClientIDs map[string]struct{} // Reserved for future use; catalog-only plugins do not populate it.
executorModelClientIDs map[string]struct{}
modelProviders map[string]string
modelRegistrations map[string]pluginModelRegistration
providerModels map[string][]*registryModelInfo
executorProviders map[string]struct{}
accessProviderKeys map[string]struct{}
commandLineFlags map[string]commandLineFlagRecord
commandLineHits map[string]struct{}
managementRoutes map[string]managementRouteRecord
resourceRoutes map[string]resourceRouteRecord
streams *streamBridge
httpStreams *hostHTTPStreamBridge
httpOperations *hostHTTPOperationBridge
modelStreams *modelStreamBridge
callbackContexts *callbackContextRegistry
snapshot atomic.Value
}
func New() *Host {
h := &Host{
applyMu: make(chan struct{}, 1),
loader: defaultPluginLoader(),
loaded: make(map[string]*loadedPlugin),
retired: make(map[string][]*loadedPlugin),
loading: make(map[string]*pluginLoadRequest),
fused: make(map[string]string),
pluginFileVersions: make(map[string]string),
activePluginVersions: make(map[string]string),
activePluginPaths: make(map[string]string),
cleanupFilesPending: true,
modelClientIDs: make(map[string]struct{}),
executorModelClientIDs: make(map[string]struct{}),
modelProviders: make(map[string]string),
modelRegistrations: make(map[string]pluginModelRegistration),
providerModels: make(map[string][]*registryModelInfo),
executorProviders: make(map[string]struct{}),
accessProviderKeys: make(map[string]struct{}),
commandLineFlags: make(map[string]commandLineFlagRecord),
commandLineHits: make(map[string]struct{}),
managementRoutes: make(map[string]managementRouteRecord),
resourceRoutes: make(map[string]resourceRouteRecord),
streams: newStreamBridge(),
httpStreams: newHostHTTPStreamBridge(),
httpOperations: newHostHTTPOperationBridge(),
modelStreams: newModelStreamBridge(),
callbackContexts: newCallbackContextRegistry(),
}
h.snapshot.Store(emptySnapshot())
return h
}
func NewForTest(loader pluginLoader) *Host {
h := New()
h.loader = loader
return h
}
func (h *Host) SetModelExecutor(executor modelExecutor) {
if h == nil {
return
}
h.mu.Lock()
h.modelExecutor = executor
h.mu.Unlock()
}
func (h *Host) currentModelExecutor() modelExecutor {
if h == nil {
return nil
}
h.mu.Lock()
executor := h.modelExecutor
h.mu.Unlock()
return executor
}
func (h *Host) Snapshot() *Snapshot {
if h == nil {
return emptySnapshot()
}
raw := h.snapshot.Load()
if snap, ok := raw.(*Snapshot); ok && snap != nil {
return snap
}
return emptySnapshot()
}
// PluginLoaded reports whether a plugin is still active in the host runtime.
func (h *Host) PluginLoaded(id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
_, ok := h.loaded[id]
if ok {
return true
}
return len(h.retired[id]) > 0
}
// PluginBusy reports whether a plugin is active in the host runtime or being loaded.
func (h *Host) PluginBusy(id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.loaded[id]; ok {
return true
}
if len(h.retired[id]) > 0 {
return true
}
_, ok := h.loading[id]
return ok
}
func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
if h == nil || !h.lockApply(ctx) {
return
}
defer h.unlockApply()
if ctx == nil {
ctx = context.Background()
}
if errContext := ctx.Err(); errContext != nil {
return
}
rc, errRuntimeConfig := runtimeConfigFromConfig(cfg)
if errRuntimeConfig != nil {
log.WithError(errRuntimeConfig).Error("failed to apply plugin runtime config")
return
}
h.mu.Lock()
h.runtimeConfig = cfg
h.mu.Unlock()
if !rc.Enabled {
h.detachUnselectedPlugins(ctx, nil)
h.mu.Lock()
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.rebuildActivePluginMapsLocked(nil)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
return
}
desiredVersions := desiredPluginVersions(rc.Items)
files, errSelect := selectPluginFiles(rc.Dir, desiredVersions)
if errSelect != nil {
log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)
h.mu.Lock()
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.rebuildActivePluginMapsLocked(nil)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
return
}
files = h.withLoadedPluginFallbacks(files, rc.Items, desiredVersions)
h.detachUnselectedPlugins(ctx, enabledPluginIDs(files, rc.Items))
records := make([]capabilityRecord, 0, len(files))
loadedFiles := make([]pluginFile, 0, len(files))
hotReloadLogs := make([]log.Fields, 0)
for _, file := range files {
item, ok := rc.Items[file.ID]
if !ok {
item = defaultRuntimeItemConfig(file.ID)
}
if !item.Enabled {
continue
}
h.mu.Lock()
lp := h.loaded[file.ID]
var replaced *loadedPlugin
if lp != nil && cleanPluginPath(lp.path) != cleanPluginPath(file.Path) {
replaced = lp
lp = nil
}
_, disabled := h.fused[file.ID]
h.mu.Unlock()
if disabled && replaced == nil {
continue
}
loadedNow := false
var hotReloadFields log.Fields
var plugin pluginapi.Plugin
registeredNow := false
if lp == nil {
request := &pluginLoadRequest{result: make(chan pluginLoadResult, 1)}
h.mu.Lock()
if _, loading := h.loading[file.ID]; loading {
h.mu.Unlock()
continue
}
h.loading[file.ID] = request
h.mu.Unlock()
if replaced != nil {
h.callQuiesce(ctx, replaced)
if errContext := ctx.Err(); errContext != nil {
h.clearLoadingRequest(file.ID, request)
_, _, _ = h.rollbackReplacement(replaced, item)
return
}
}
h.startPluginLoad(ctx, file, item, request)
loadResult, completed := h.waitForPluginLoad(ctx, request)
if !completed {
if replaced == nil {
h.cleanupCanceledPluginLoad(file.ID, request)
} else {
h.cleanupCanceledPluginLoadAndWait(file.ID, request)
_, _, _ = h.rollbackReplacement(replaced, item)
}
return
}
if loadResult.err != nil || (replaced != nil && !loadResult.initialized) {
if replaced == nil {
h.cleanupPluginLoad(file.ID, request, loadResult.loaded)
} else {
h.cleanupPluginLoadAndWait(file.ID, request, loadResult.loaded)
if rollbackRecord, rollbackFile, okRollback := h.rollbackReplacement(replaced, item); okRollback {
records = append(records, rollbackRecord)
loadedFiles = append(loadedFiles, rollbackFile)
}
}
if loadResult.err != nil {
log.Warnf("pluginhost: failed to load plugin %s from %s: %v", file.ID, file.Path, loadResult.err)
}
continue
}
h.mu.Lock()
if h.loading[file.ID] != request {
h.mu.Unlock()
h.discardLoadedPlugin(loadResult.loaded)
if replaced != nil {
_, _, _ = h.rollbackReplacement(replaced, item)
}
return
}
if errContext := ctx.Err(); errContext != nil {
h.mu.Unlock()
if replaced == nil {
h.cleanupPluginLoad(file.ID, request, loadResult.loaded)
} else {
h.cleanupPluginLoadAndWait(file.ID, request, loadResult.loaded)
_, _, _ = h.rollbackReplacement(replaced, item)
}
return
}
delete(h.loading, file.ID)
lp = loadResult.loaded
if replaced != nil {
hotReloadFields = pluginHotReloadLogFields(file.ID, file.Version, file.Path, replaced.version, replaced.path)
h.retireLoadedPluginLocked(replaced)
delete(h.fused, file.ID)
h.removePluginRuntimeStateLocked(file.ID)
}
h.loaded[file.ID] = lp
loadedNow = true
plugin = loadResult.plugin
registeredNow = loadResult.initialized
h.mu.Unlock()
log.WithFields(pluginLogFields(file.ID, "", file.Version, file.Path)).Info("pluginhost: plugin loaded")
}
if !registeredNow {
if loadedNow {
continue
}
var okCall bool
plugin, okCall = h.callRegister(ctx, lp, item)
if !okCall {
continue
}
}
plugin.Metadata = clonePluginMetadata(plugin.Metadata)
h.mu.Lock()
if lp != nil {
lp.name = strings.TrimSpace(plugin.Metadata.Name)
if strings.TrimSpace(lp.version) == "" {
lp.version = strings.TrimSpace(plugin.Metadata.Version)
}
}
h.mu.Unlock()
if loadedNow {
log.WithFields(pluginLogFieldsFromMetadata(file.ID, plugin.Metadata, file.Path)).Info("pluginhost: plugin registered")
}
if hotReloadFields != nil {
hotReloadLogs = append(hotReloadLogs, hotReloadFields)
}
records = append(records, capabilityRecord{
id: file.ID,
path: file.Path,
version: file.Version,
priority: item.Priority,
meta: plugin.Metadata,
plugin: plugin,
})
loadedFiles = append(loadedFiles, file)
}
sortRecords(records)
h.mu.Lock()
cleanupFiles := h.cleanupFilesPending
if len(loadedFiles) > 0 {
h.cleanupFilesPending = false
}
h.rebuildActivePluginMapsLocked(records)
h.snapshot.Store(&Snapshot{enabled: true, records: records, quotaSupportedProviders: make(map[string][]string)})
h.mu.Unlock()
h.refreshThinkingProviders(records)
for _, fields := range hotReloadLogs {
log.WithFields(fields).Info("pluginhost: plugin hot reloaded")
}
if cleanupFiles && len(loadedFiles) > 0 {
if errCleanup := cleanupUnselectedPluginFiles(rc.Dir, loadedFiles); errCleanup != nil {
log.Warnf("pluginhost: failed to clean old plugin files: %v", errCleanup)
}
}
}
func (h *Host) registerHostCallbackInstance(pluginID string, instance *hostCallbackInstance) {
if h == nil || instance == nil {
return
}
pluginID = strings.TrimSpace(pluginID)
h.mu.Lock()
if request := h.loading[pluginID]; request != nil {
if request.callbackInstances == nil {
request.callbackInstances = make(map[*hostCallbackInstance]struct{})
}
request.callbackInstances[instance] = struct{}{}
if request.closed {
instance.closed.Store(true)
}
}
h.mu.Unlock()
}
func markPluginLoadClosedLocked(request *pluginLoadRequest) []*hostCallbackInstance {
if request == nil {
return nil
}
request.closed = true
instances := make([]*hostCallbackInstance, 0, len(request.callbackInstances))
for instance := range request.callbackInstances {
instance.closed.Store(true)
instances = append(instances, instance)
}
return instances
}
func (h *Host) closeHostHTTPCallbackInstances(pluginID string, instances []*hostCallbackInstance) {
for _, instance := range instances {
h.closeHostHTTPCallbackInstance(pluginID, instance)
}
}
func (h *Host) startPluginLoad(ctx context.Context, file pluginFile, item runtimeItemConfig, request *pluginLoadRequest) {
if h == nil || request == nil || request.result == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
go func() {
client, errOpen := h.loader.Open(file, h)
if errOpen != nil {
request.result <- pluginLoadResult{err: errOpen}
return
}
if client == nil {
request.result <- pluginLoadResult{err: fmt.Errorf("plugin loader returned nil client")}
return
}
guardedClient := newGuardedPluginClient(client)
loaded := &loadedPlugin{
id: file.ID,
path: file.Path,
version: file.Version,
client: guardedClient,
callbackInstance: pluginCallbackInstance(guardedClient),
}
plugin, okCall := h.callRegister(ctx, loaded, item)
request.result <- pluginLoadResult{loaded: loaded, plugin: plugin, initialized: okCall}
}()
}
func (h *Host) waitForPluginLoad(ctx context.Context, request *pluginLoadRequest) (pluginLoadResult, bool) {
if h == nil || request == nil || request.result == nil {
return pluginLoadResult{}, false
}
if ctx == nil {
ctx = context.Background()
}
select {
case result := <-request.result:
return result, true
case <-ctx.Done():
return pluginLoadResult{}, false
}
}
func (h *Host) cleanupCanceledPluginLoad(id string, request *pluginLoadRequest) {
if h == nil || request == nil || request.result == nil {
return
}
h.mu.Lock()
if h.loading[id] != request || request.cleanupStarted {
h.mu.Unlock()
return
}
request.cleanupStarted = true
instances := markPluginLoadClosedLocked(request)
h.mu.Unlock()
h.closeHostHTTPCallbackInstances(id, instances)
go func() {
result := <-request.result
h.finishPluginLoadCleanup(id, request, result.loaded)
}()
}
func (h *Host) cleanupCanceledPluginLoadAndWait(id string, request *pluginLoadRequest) {
if h == nil || request == nil || request.result == nil {
return
}
h.mu.Lock()
if h.loading[id] != request || request.cleanupStarted {
h.mu.Unlock()
return
}
request.cleanupStarted = true
instances := markPluginLoadClosedLocked(request)
h.mu.Unlock()
h.closeHostHTTPCallbackInstances(id, instances)
result := <-request.result
h.discardLoadedPlugin(result.loaded)
h.clearLoadingRequest(id, request)
}
// cleanupPluginLoad retains the matching load token until the client has physically
// shut down, preventing a replacement ApplyConfig from opening a second client.
func (h *Host) cleanupPluginLoad(id string, request *pluginLoadRequest, loaded *loadedPlugin) {
if h == nil || request == nil {
return
}
h.mu.Lock()
if h.loading[id] != request || request.cleanupStarted {
h.mu.Unlock()
return
}
request.cleanupStarted = true
instances := markPluginLoadClosedLocked(request)
h.mu.Unlock()
h.closeHostHTTPCallbackInstances(id, instances)
h.finishPluginLoadCleanup(id, request, loaded)
}
func (h *Host) cleanupPluginLoadAndWait(id string, request *pluginLoadRequest, loaded *loadedPlugin) {
if h == nil || request == nil {
return
}
h.mu.Lock()
if h.loading[id] != request || request.cleanupStarted {
h.mu.Unlock()
return
}
request.cleanupStarted = true
instances := markPluginLoadClosedLocked(request)
h.mu.Unlock()
h.closeHostHTTPCallbackInstances(id, instances)
h.discardLoadedPlugin(loaded)
h.clearLoadingRequest(id, request)
}
func (h *Host) finishPluginLoadCleanup(id string, request *pluginLoadRequest, loaded *loadedPlugin) {
go func() {
h.discardLoadedPlugin(loaded)
h.clearLoadingRequest(id, request)
}()
}
func (h *Host) clearLoadingRequest(id string, request *pluginLoadRequest) {
if h == nil || request == nil {
return
}
h.mu.Lock()
if h.loading[id] == request {
delete(h.loading, id)
}
h.mu.Unlock()
}
func (h *Host) discardLoadedPlugin(loaded *loadedPlugin) {
if loaded == nil || loaded.client == nil {
return
}
h.closeHostHTTPCallbackInstance(loaded.id, loaded.callbackInstance)
shutdownPluginClient(context.Background(), loaded.client)
}
func (h *Host) withLoadedPluginFallbacks(files []pluginFile, items map[string]runtimeItemConfig, desired map[string]string) []pluginFile {
if h == nil || len(desired) == 0 {
return files
}
selected := make(map[string]struct{}, len(files))
for _, file := range files {
id := strings.TrimSpace(file.ID)
if id != "" {
selected[id] = struct{}{}
}
}
ids := make([]string, 0, len(desired))
for id := range desired {
ids = append(ids, id)
}
sort.Strings(ids)
h.mu.Lock()
defer h.mu.Unlock()
for _, id := range ids {
if _, ok := selected[id]; ok {
continue
}
if item, ok := items[id]; ok && !item.Enabled {
continue
}
lp := h.loaded[id]
if lp == nil || strings.TrimSpace(lp.path) == "" {
continue
}
files = append(files, pluginFile{
ID: id,
Path: lp.path,
Version: strings.TrimSpace(lp.version),
})
selected[id] = struct{}{}
}
return files
}
// UnloadPlugin removes one plugin from the active runtime.
// The plugin image itself stays mapped for the lifetime of the process.
func (h *Host) UnloadPlugin(id string) bool {
return h.UnloadPluginContext(context.Background(), id)
}
// UnloadPluginContext detaches a plugin from the runtime before waiting for its
// active calls. Physical client cleanup continues after cancellation if needed.
func (h *Host) UnloadPluginContext(ctx context.Context, id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" || !h.lockApply(ctx) {
return false
}
defer h.unlockApply()
return h.detachPlugin(ctx, id)
}
// detachPlugin shuts down one loaded plugin. Caller must hold the apply lock.
func (h *Host) detachPlugin(ctx context.Context, id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" {
return false
}
if ctx == nil {
ctx = context.Background()
}
targets := make([]pluginUnloadTarget, 0)
h.mu.Lock()
lp := h.loaded[id]
if lp != nil {
targets = append(targets, pluginUnloadTarget{id: lp.id, name: lp.name, path: lp.path, version: lp.version, client: lp.client, callbackInstance: lp.callbackInstance})
}
for _, retired := range h.retired[id] {
if retired == nil {
continue
}
targets = append(targets, pluginUnloadTarget{id: retired.id, name: retired.name, path: retired.path, version: retired.version, client: retired.client, callbackInstance: retired.callbackInstance})
}
request := h.loading[id]
instances := markPluginLoadClosedLocked(request)
if len(targets) == 0 && request == nil {
h.mu.Unlock()
return false
}
delete(h.loaded, id)
delete(h.retired, id)
delete(h.fused, id)
delete(h.activePluginVersions, id)
delete(h.activePluginPaths, id)
for _, target := range targets {
delete(h.pluginFileVersions, cleanPluginPath(target.path))
}
records, enabled := h.snapshotWithoutPluginLocked(id)
h.removePluginRuntimeStateLocked(id)
h.snapshot.Store(&Snapshot{enabled: enabled, records: records})
h.mu.Unlock()
h.closeHostHTTPCallbackInstances(id, instances)
if len(targets) == 0 {
h.closeHostHTTPPluginResources(id, nil)
}
if request != nil {
h.cleanupCanceledPluginLoad(id, request)
}
for _, target := range targets {
h.closeHostHTTPPluginResources(target.id, target.callbackInstance)
}
h.refreshThinkingProviders(records)
h.RegisterFrontendAuthProviders()
for _, target := range targets {
if target.client != nil {
shutdownPluginClient(ctx, target.client)
}
log.WithFields(pluginLogFields(target.id, target.name, target.version, target.path)).Info("pluginhost: plugin unloaded")
}
for _, target := range targets {
h.closeHostHTTPPluginResources(target.id, target.callbackInstance)
}
return true
}
// detachUnselectedPlugins stops plugins that are no longer enabled. nil keeps none.
// Caller must hold the apply lock.
func (h *Host) detachUnselectedPlugins(ctx context.Context, selected map[string]struct{}) {
if h == nil {
return
}
h.mu.Lock()
ids := make([]string, 0, len(h.loaded)+len(h.retired)+len(h.loading))
for id := range h.loaded {
ids = append(ids, id)
}
for id := range h.retired {
ids = append(ids, id)
}
for id := range h.loading {
ids = append(ids, id)
}
h.mu.Unlock()
sort.Strings(ids)
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if _, ok := selected[id]; ok {
continue
}
h.detachPlugin(ctx, id)
}
}
func enabledPluginIDs(files []pluginFile, items map[string]runtimeItemConfig) map[string]struct{} {
ids := make(map[string]struct{}, len(files))
for _, file := range files {
id := strings.TrimSpace(file.ID)
if id == "" {
continue
}
item, ok := items[id]
if !ok {
item = defaultRuntimeItemConfig(id)
}
if !item.Enabled {
continue
}
ids[id] = struct{}{}
}
return ids
}
// ShutdownAll removes active plugin capabilities and detaches all plugins.
// Plugin images stay mapped for the lifetime of the process.
func (h *Host) ShutdownAll() {
h.ShutdownAllContext(context.Background())
}
// ShutdownAllContext detaches all plugin runtime state without waiting beyond ctx
// for active plugin calls to complete.
func (h *Host) ShutdownAllContext(ctx context.Context) {
if h == nil || !h.lockApply(ctx) {
return
}
defer h.unlockApply()
targets := make([]pluginUnloadTarget, 0)
var loading map[string]*pluginLoadRequest
loadingInstances := make(map[string][]*hostCallbackInstance)
h.mu.Lock()
loading = make(map[string]*pluginLoadRequest, len(h.loading))
for id, request := range h.loading {
loading[id] = request
loadingInstances[id] = markPluginLoadClosedLocked(request)
}
for _, lp := range h.loaded {
if lp == nil || lp.client == nil {
continue
}
targets = append(targets, pluginUnloadTarget{
id: lp.id,
name: lp.name,
path: lp.path,
version: lp.version,
client: lp.client,
callbackInstance: lp.callbackInstance,
})
}
for _, retiredPlugins := range h.retired {
for _, lp := range retiredPlugins {
if lp == nil || lp.client == nil {
continue
}
targets = append(targets, pluginUnloadTarget{
id: lp.id,
name: lp.name,
path: lp.path,
version: lp.version,
client: lp.client,
callbackInstance: lp.callbackInstance,
})
}
}
h.loaded = make(map[string]*loadedPlugin)
h.retired = make(map[string][]*loadedPlugin)
h.modelClientIDs = make(map[string]struct{})
h.executorModelClientIDs = make(map[string]struct{})
h.modelProviders = make(map[string]string)
h.modelRegistrations = make(map[string]pluginModelRegistration)
h.providerModels = make(map[string][]*registryModelInfo)
h.executorProviders = make(map[string]struct{})
h.commandLineFlags = make(map[string]commandLineFlagRecord)
h.commandLineHits = make(map[string]struct{})
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.pluginFileVersions = make(map[string]string)
h.activePluginVersions = make(map[string]string)
h.activePluginPaths = make(map[string]string)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
for id, instances := range loadingInstances {
h.closeHostHTTPCallbackInstances(id, instances)
}
for _, target := range targets {
h.closeHostHTTPPluginResources(target.id, target.callbackInstance)
}
if h.httpOperations != nil {
h.httpOperations.cancelAll()
}
if h.httpStreams != nil {
h.httpStreams.closeAll()
}
h.refreshThinkingProviders(nil)
h.RegisterFrontendAuthProviders()
for id, request := range loading {
h.cleanupCanceledPluginLoad(id, request)
}
for _, target := range targets {
shutdownPluginClient(ctx, target.client)
log.WithFields(pluginLogFields(target.id, target.name, target.version, target.path)).Info("pluginhost: plugin unloaded")
}
if h.httpOperations != nil {
h.httpOperations.cancelAll()
}
if h.httpStreams != nil {
h.httpStreams.closeAll()
}
}
func (h *Host) lockApply(ctx context.Context) bool {
if h == nil {
return false
}
if ctx == nil {
ctx = context.Background()
}
select {
case h.applyMu <- struct{}{}:
return true
default:
}
select {
case h.applyMu <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
func (h *Host) unlockApply() {
<-h.applyMu
}
func shutdownPluginClient(ctx context.Context, client pluginClient) {
if client == nil {
return
}
if guarded, ok := client.(*guardedPluginClient); ok {
guarded.ShutdownContext(ctx)
return
}
client.Shutdown()
}
func cleanPluginPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return filepath.Clean(path)
}
func (h *Host) retireLoadedPluginLocked(lp *loadedPlugin) {
if h == nil || lp == nil {
return
}
h.retired[lp.id] = append(h.retired[lp.id], lp)
}
func (h *Host) recordCurrent(record capabilityRecord) bool {
return h.pluginIdentityCurrent(record.id, record.path, record.version)
}
func (h *Host) pluginIdentityCurrent(id string, path string, version string) bool {
if h == nil {
return false
}
version = strings.TrimSpace(version)
h.mu.Lock()
defer h.mu.Unlock()
id = strings.TrimSpace(id)
if id == "" {
return false
}
path = cleanPluginPath(path)
if path == "" || h.activePluginPaths[id] != path {
return false
}
activePathVersion, okVersion := h.pluginFileVersions[path]
if !okVersion || activePathVersion != version {
return false
}
return h.activePluginVersions[id] == version
}
func (h *Host) snapshotWithoutPluginLocked(id string) ([]capabilityRecord, bool) {
raw := h.snapshot.Load()
snap, _ := raw.(*Snapshot)
if snap == nil || len(snap.records) == 0 {
return nil, snap != nil && snap.enabled
}
records := make([]capabilityRecord, 0, len(snap.records))
for _, record := range snap.records {
if record.id == id {
continue
}
records = append(records, record)
}
return records, snap.enabled
}
func (h *Host) removePluginRuntimeStateLocked(id string) {
for key, record := range h.managementRoutes {
if record.pluginID == id {
delete(h.managementRoutes, key)
}
}
for key, record := range h.resourceRoutes {
if record.pluginID == id {
delete(h.resourceRoutes, key)
}
}
for name, record := range h.commandLineFlags {
if record.pluginID == id {
delete(h.commandLineFlags, name)
delete(h.commandLineHits, name)
}
}
if registration, ok := h.modelRegistrations[id]; ok {
delete(h.providerModels, registration.provider)
}
delete(h.modelProviders, id)
delete(h.modelRegistrations, id)
}
func (h *Host) rebuildActivePluginMapsLocked(records []capabilityRecord) {
h.pluginFileVersions = make(map[string]string, len(records))
h.activePluginVersions = make(map[string]string, len(records))
h.activePluginPaths = make(map[string]string, len(records))
for _, record := range records {
id := strings.TrimSpace(record.id)
path := cleanPluginPath(record.path)
if id == "" || path == "" {
continue
}
h.pluginFileVersions[path] = strings.TrimSpace(record.version)
h.activePluginVersions[id] = strings.TrimSpace(record.version)
h.activePluginPaths[id] = path
}
}
func (h *Host) callQuiesce(ctx context.Context, lp *loadedPlugin) bool {
if h == nil || lp == nil || lp.client == nil {
return false
}
errQuiesce, okCall := h.safePluginAction(ctx, lp.id, pluginabi.MethodPluginQuiesce, func() error {
_, errCall := callPlugin[rpcEmptyResponse](ctx, lp.client, pluginabi.MethodPluginQuiesce, rpcEmptyResponse{})
return errCall
})
if errQuiesce != nil {
logQuiesceError(lp.id, errQuiesce)
}
return okCall && errQuiesce == nil
}
func logQuiesceError(id string, errQuiesce error) {
entry := log.WithError(errQuiesce).WithField("plugin_id", id)
switch {
case quiesceUnsupported(errQuiesce):
entry.Debug("pluginhost: plugin quiesce unsupported")
case errors.Is(errQuiesce, context.Canceled), errors.Is(errQuiesce, context.DeadlineExceeded):
entry.Debug("pluginhost: plugin quiesce canceled")
default:
entry.Warn("pluginhost: plugin quiesce failed")
}
}
func quiesceUnsupported(errQuiesce error) bool {
if errQuiesce == nil {
return false
}
var errRPC rpcError
if errors.As(errQuiesce, &errRPC) {
switch strings.ToLower(strings.TrimSpace(errRPC.Code)) {
case "unknown_method", "method_not_found", "unsupported_method":
return true
}
}
if errors.Is(errQuiesce, errors.ErrUnsupported) {
return true
}
message := strings.ToLower(strings.TrimSpace(errQuiesce.Error()))
for _, indication := range []string{
"unknown method",
"method not found",
"unsupported method",
"method unsupported",
"method is not supported",
"method not supported",
} {
if strings.Contains(message, indication) {
return true
}
}
return false
}
func (h *Host) rollbackReplacement(lp *loadedPlugin, item runtimeItemConfig) (capabilityRecord, pluginFile, bool) {
if h == nil || lp == nil {
return capabilityRecord{}, pluginFile{}, false
}
h.mu.Lock()
configYAML := bytes.Clone(lp.configYAML)
previousPlugin := lp.plugin
h.mu.Unlock()
if len(configYAML) > 0 {
item.ConfigYAML = configYAML
}
plugin, okCall := h.callRegister(context.Background(), lp, item)
if okCall {
h.mu.Lock()
delete(h.fused, lp.id)
h.mu.Unlock()
} else {
plugin = previousPlugin
}
if !validPlugin(plugin) {
return capabilityRecord{}, pluginFile{}, false
}
return capabilityRecord{
id: lp.id,
path: lp.path,
version: lp.version,
priority: item.Priority,
meta: plugin.Metadata,
plugin: plugin,
}, pluginFile{
ID: lp.id,
Path: lp.path,
Version: lp.version,
}, true
}
func (h *Host) callRegister(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) {
if lp == nil {
return pluginapi.Plugin{}, false
}
method := pluginabi.MethodPluginRegister
h.mu.Lock()
registered := lp.registered
h.mu.Unlock()
if registered {
method = pluginabi.MethodPluginReconfigure
}
plugin, okCall := h.safePluginCall(ctx, lp.id, method, func() pluginapi.Plugin {
plugin, errRegister := registerRPCPlugin(ctx, h, lp.id, lp.client, method, item.ConfigYAML)
if errRegister != nil {
log.Warnf("pluginhost: plugin %s %s failed: %v", lp.id, method, errRegister)
return pluginapi.Plugin{}
}
return plugin
})
if !okCall {
return pluginapi.Plugin{}, false
}
h.mu.Lock()
lp.registered = true
h.mu.Unlock()
if !validPlugin(plugin) {
log.Warnf("pluginhost: plugin %s returned invalid metadata or no capabilities", lp.id)
return pluginapi.Plugin{}, false
}
plugin.Metadata = clonePluginMetadata(plugin.Metadata)
h.mu.Lock()
lp.name = strings.TrimSpace(plugin.Metadata.Name)
if strings.TrimSpace(lp.version) == "" {
lp.version = strings.TrimSpace(plugin.Metadata.Version)
}
lp.configYAML = bytes.Clone(item.ConfigYAML)
lp.plugin = plugin
h.mu.Unlock()
return plugin, true
}
func (h *Host) safePluginAction(ctx context.Context, id, method string, fn func() error) (err error, ok bool) {
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(id, method, recovered)
err = nil
ok = false
}
}()
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err(), false
default:
}
}
return fn(), true
}
func (h *Host) safePluginCall(ctx context.Context, id, method string, fn func() pluginapi.Plugin) (out pluginapi.Plugin, ok bool) {
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(id, method, recovered)
out = pluginapi.Plugin{}
ok = false
}
}()
if ctx != nil {
select {
case <-ctx.Done():
return pluginapi.Plugin{}, false
default:
}
}
return fn(), true
}
func validPlugin(plugin pluginapi.Plugin) bool {
if strings.TrimSpace(plugin.Metadata.Name) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.Version) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.Author) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.GitHubRepository) == "" {
return false
}
caps := plugin.Capabilities
return caps.ModelRegistrar != nil ||
caps.ModelProvider != nil ||
caps.AuthProvider != nil ||
caps.FrontendAuthProvider != nil ||
caps.Scheduler != nil ||
caps.ModelRouter != nil ||
caps.Executor != nil ||
caps.RequestTranslator != nil ||
caps.RequestNormalizer != nil ||
caps.RequestInterceptor != nil ||
caps.RequestLifecyclePlugin != nil ||
caps.ResponseTranslator != nil ||
caps.ResponseBeforeTranslator != nil ||
caps.ResponseAfterTranslator != nil ||
caps.ResponseInterceptor != nil ||
caps.StreamChunkInterceptor != nil ||
caps.WebSocketResponseObserver != nil ||
caps.ThinkingApplier != nil ||
caps.UsagePlugin != nil ||
caps.CommandLinePlugin != nil ||
caps.ManagementAPI != nil ||
caps.QuotaProvider != nil
}
func typeName(v any) string {
return fmt.Sprintf("%T", v)
}