Files
CLIProxyAPI/internal/pluginhost/adapters.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

510 lines
16 KiB
Go

package pluginhost
import (
"context"
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
_ "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator/builtin"
log "github.com/sirupsen/logrus"
)
type registryModelInfo = registry.ModelInfo
type modelRegistry interface {
RegisterClient(clientID, clientProvider string, models []*registry.ModelInfo)
UnregisterClient(clientID string)
}
type modelProviderRegistry interface {
modelRegistry
GetModelProviders(modelID string) []string
}
type pluginModelRegistration struct {
pluginID string
provider string
priority int
models []*registry.ModelInfo
hasExecutor bool
}
func normalizedExecutorModelScope(caps pluginapi.Capabilities) pluginapi.ExecutorModelScope {
if caps.Executor == nil {
return pluginapi.ExecutorModelScopeBoth
}
switch caps.ExecutorModelScope {
case pluginapi.ExecutorModelScopeStatic, pluginapi.ExecutorModelScopeOAuth, pluginapi.ExecutorModelScopeBoth:
return caps.ExecutorModelScope
default:
return pluginapi.ExecutorModelScopeBoth
}
}
func executorScopeAllowsStaticModels(caps pluginapi.Capabilities) bool {
if caps.Executor == nil {
return true
}
scope := normalizedExecutorModelScope(caps)
return scope == pluginapi.ExecutorModelScopeStatic || scope == pluginapi.ExecutorModelScopeBoth
}
func executorScopeAllowsOAuthModels(caps pluginapi.Capabilities) bool {
if caps.Executor == nil {
return true
}
scope := normalizedExecutorModelScope(caps)
return scope == pluginapi.ExecutorModelScopeOAuth || scope == pluginapi.ExecutorModelScopeBoth
}
func normalizeExecutorFormats(raw []string) []sdktranslator.Format {
if len(raw) == 0 {
return nil
}
out := make([]sdktranslator.Format, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
format := normalizeExecutorFormatName(item)
if format == "" {
continue
}
key := format.String()
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, format)
}
return out
}
func normalizeExecutorFormatName(raw string) sdktranslator.Format {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "none":
return ""
case "chat-completions", "chat_completions", "openai-chat-completions", "openai_chat_completions":
return sdktranslator.FormatOpenAI
case "responses", "openai-responses", "openai_responses":
return sdktranslator.FormatOpenAIResponse
case "anthropic":
return sdktranslator.FormatClaude
default:
return sdktranslator.FromString(strings.TrimSpace(raw))
}
}
func executorFormatContains(formats []sdktranslator.Format, target sdktranslator.Format) bool {
if target == "" {
return false
}
for _, format := range formats {
if format == target {
return true
}
}
return false
}
type AuthModelResult struct {
Provider string
Models []*registry.ModelInfo
Auth *coreauth.Auth
Handled bool
Err error
}
func pluginModelInfoToRegistryModelInfo(model pluginapi.ModelInfo) *registry.ModelInfo {
return &registry.ModelInfo{
ID: model.ID,
Object: model.Object,
Created: model.Created,
OwnedBy: model.OwnedBy,
Type: model.Type,
DisplayName: model.DisplayName,
Name: model.Name,
Version: model.Version,
Description: model.Description,
InputTokenLimit: int(model.InputTokenLimit),
OutputTokenLimit: int(model.OutputTokenLimit),
SupportedGenerationMethods: cloneStringSlice(model.SupportedGenerationMethods),
ContextLength: int(model.ContextLength),
MaxCompletionTokens: int(model.MaxCompletionTokens),
SupportedParameters: cloneStringSlice(model.SupportedParameters),
SupportedInputModalities: cloneStringSlice(model.SupportedInputModalities),
SupportedOutputModalities: cloneStringSlice(model.SupportedOutputModalities),
Thinking: pluginThinkingSupportToRegistryThinkingSupport(model.Thinking),
UserDefined: model.UserDefined,
}
}
func pluginThinkingSupportToRegistryThinkingSupport(thinking *pluginapi.ThinkingSupport) *registry.ThinkingSupport {
if thinking == nil {
return nil
}
return &registry.ThinkingSupport{
Min: thinking.Min,
Max: thinking.Max,
ZeroAllowed: thinking.ZeroAllowed,
DynamicAllowed: thinking.DynamicAllowed,
DisableType: thinking.DisableType,
Levels: cloneStringSlice(thinking.Levels),
}
}
func registryModelInfoToPluginModelInfo(model *registry.ModelInfo) pluginapi.ModelInfo {
if model == nil {
return pluginapi.ModelInfo{}
}
return pluginapi.ModelInfo{
ID: model.ID,
Object: model.Object,
Created: model.Created,
OwnedBy: model.OwnedBy,
Type: model.Type,
DisplayName: model.DisplayName,
Name: model.Name,
Version: model.Version,
Description: model.Description,
InputTokenLimit: int64(model.InputTokenLimit),
OutputTokenLimit: int64(model.OutputTokenLimit),
SupportedGenerationMethods: cloneStringSlice(model.SupportedGenerationMethods),
ContextLength: int64(model.ContextLength),
MaxCompletionTokens: int64(model.MaxCompletionTokens),
SupportedParameters: cloneStringSlice(model.SupportedParameters),
SupportedInputModalities: cloneStringSlice(model.SupportedInputModalities),
SupportedOutputModalities: cloneStringSlice(model.SupportedOutputModalities),
Thinking: registryThinkingSupportToPluginThinkingSupport(model.Thinking),
UserDefined: model.UserDefined,
}
}
func registryThinkingSupportToPluginThinkingSupport(thinking *registry.ThinkingSupport) *pluginapi.ThinkingSupport {
if thinking == nil {
return nil
}
return &pluginapi.ThinkingSupport{
Min: thinking.Min,
Max: thinking.Max,
ZeroAllowed: thinking.ZeroAllowed,
DynamicAllowed: thinking.DynamicAllowed,
DisableType: thinking.DisableType,
Levels: cloneStringSlice(thinking.Levels),
}
}
func cloneStringSlice(in []string) []string {
if len(in) == 0 {
return nil
}
return append([]string(nil), in...)
}
func cloneRegistryModels(in []*registry.ModelInfo) []*registry.ModelInfo {
if len(in) == 0 {
return nil
}
out := make([]*registry.ModelInfo, 0, len(in))
for _, model := range in {
if model == nil {
continue
}
copyModel := *model
copyModel.SupportedGenerationMethods = cloneStringSlice(model.SupportedGenerationMethods)
copyModel.SupportedParameters = cloneStringSlice(model.SupportedParameters)
copyModel.SupportedInputModalities = cloneStringSlice(model.SupportedInputModalities)
copyModel.SupportedOutputModalities = cloneStringSlice(model.SupportedOutputModalities)
if model.Thinking != nil {
thinking := *model.Thinking
thinking.Levels = cloneStringSlice(model.Thinking.Levels)
copyModel.Thinking = &thinking
}
out = append(out, &copyModel)
}
return out
}
// RegisterModels collects static catalogs for executor and per-auth registration.
// Catalog-only plugins must not advertise availability independently of credentials.
func (h *Host) RegisterModels(ctx context.Context, modelRegistry modelRegistry) {
if h == nil || modelRegistry == nil {
return
}
snap := h.Snapshot()
records := h.activeRecordsFromSnapshot(snap)
nextProviders := make(map[string]string)
nextModelRegistrations := make(map[string]pluginModelRegistration)
for _, record := range records {
modelProvider := record.plugin.Capabilities.ModelProvider
registrar := record.plugin.Capabilities.ModelRegistrar
if modelProvider == nil && registrar == nil {
continue
}
if !executorScopeAllowsStaticModels(record.plugin.Capabilities) {
continue
}
var resp pluginapi.ModelRegistrationResponse
var errRegisterModels error
if modelProvider != nil {
modelResp, errStaticModels := h.callModelProviderStaticModels(ctx, record, modelProvider)
errRegisterModels = errStaticModels
resp = pluginapi.ModelRegistrationResponse{
Provider: modelResp.Provider,
Models: modelResp.Models,
}
} else {
resp, errRegisterModels = h.callModelRegistrar(ctx, record, registrar)
}
if errRegisterModels != nil {
log.Warnf("pluginhost: model registrar %s failed: %v", record.id, errRegisterModels)
continue
}
provider := strings.ToLower(strings.TrimSpace(resp.Provider))
if provider == "" || len(resp.Models) == 0 {
continue
}
models := make([]*registry.ModelInfo, 0, len(resp.Models))
for _, item := range resp.Models {
model := pluginModelInfoToRegistryModelInfo(item)
if model == nil || strings.TrimSpace(model.ID) == "" {
continue
}
model.ID = strings.TrimSpace(model.ID)
models = append(models, model)
}
if len(models) == 0 {
continue
}
nextModelRegistrations[record.id] = pluginModelRegistration{
pluginID: record.id,
provider: provider,
priority: record.priority,
models: cloneRegistryModels(models),
hasExecutor: record.plugin.Capabilities.Executor != nil,
}
nextProviders[record.id] = provider
}
h.mu.Lock()
defer h.mu.Unlock()
if h.Snapshot() != snap {
return
}
h.modelProviders = nextProviders
h.modelRegistrations = nextModelRegistrations
}
// HasAuthModelProvider reports whether an active plugin declares per-auth model
// discovery for provider. It does not fetch models or refresh credentials.
func (h *Host) HasAuthModelProvider(provider string) bool {
if h == nil {
return false
}
provider = normalizeProviderID(provider)
if provider == "" {
return false
}
for _, record := range h.activeRecords() {
if h.authModelProviderMatches(record, provider) {
return true
}
}
return false
}
func (h *Host) authModelProviderMatches(record capabilityRecord, provider string) bool {
caps := record.plugin.Capabilities
if caps.ModelProvider == nil || h.isPluginFused(record.id) || !executorScopeAllowsOAuthModels(caps) {
return false
}
if caps.AuthProvider != nil {
identifier, ok := h.callAuthProviderIdentifier(record.id, caps.AuthProvider)
return ok && normalizeProviderID(identifier) == provider
}
candidate := normalizeProviderID(h.modelProvider(record.id))
if candidate == "" && caps.Executor != nil {
candidate, _ = h.executorProvider(record, caps.Executor)
}
return candidate == provider
}
func (h *Host) ModelsForAuth(ctx context.Context, auth *coreauth.Auth) AuthModelResult {
if h == nil || auth == nil {
return AuthModelResult{}
}
providerKey := normalizeProviderID(auth.Provider)
if providerKey == "" {
return AuthModelResult{}
}
for _, record := range h.activeRecords() {
if !h.authModelProviderMatches(record, providerKey) {
continue
}
resp, errModels := h.callModelsForAuth(ctx, record, record.plugin.Capabilities.ModelProvider, auth)
if errModels != nil {
log.Warnf("pluginhost: models for auth %s failed: %v", auth.ID, errModels)
return AuthModelResult{Handled: true, Err: errModels}
}
respProvider := normalizeProviderID(resp.Provider)
if respProvider != "" && respProvider != providerKey {
continue
}
if respProvider == "" {
respProvider = providerKey
}
models := make([]*registry.ModelInfo, 0, len(resp.Models))
for _, item := range resp.Models {
model := pluginModelInfoToRegistryModelInfo(item)
if model != nil {
model.ID = strings.TrimSpace(model.ID)
}
if model != nil && model.ID != "" {
models = append(models, model)
}
}
path := ""
if auth.Attributes != nil {
path = auth.Attributes["path"]
}
var updated *coreauth.Auth
if authDataHasValue(resp.AuthUpdate) {
updated = h.AuthDataToCoreAuth(authDataWithDefaults(resp.AuthUpdate, auth), path, auth.FileName)
}
return AuthModelResult{Provider: respProvider, Models: models, Auth: updated, Handled: true}
}
return AuthModelResult{}
}
func authDataHasValue(data pluginapi.AuthData) bool {
return strings.TrimSpace(data.Provider) != "" ||
strings.TrimSpace(data.ID) != "" ||
strings.TrimSpace(data.FileName) != "" ||
strings.TrimSpace(data.Label) != "" ||
strings.TrimSpace(data.Prefix) != "" ||
strings.TrimSpace(data.ProxyURL) != "" ||
data.Disabled ||
len(data.StorageJSON) > 0 ||
len(data.Metadata) > 0 ||
len(data.Attributes) > 0 ||
!data.NextRefreshAfter.IsZero()
}
func authDataWithDefaults(data pluginapi.AuthData, auth *coreauth.Auth) pluginapi.AuthData {
if auth == nil {
return data
}
if strings.TrimSpace(data.Provider) == "" {
data.Provider = auth.Provider
}
if strings.TrimSpace(data.ID) == "" {
data.ID = auth.ID
}
if strings.TrimSpace(data.FileName) == "" {
data.FileName = auth.FileName
}
if strings.TrimSpace(data.Label) == "" {
data.Label = auth.Label
}
if strings.TrimSpace(data.Prefix) == "" {
data.Prefix = auth.Prefix
}
if strings.TrimSpace(data.ProxyURL) == "" {
data.ProxyURL = auth.ProxyURL
}
if len(data.Metadata) == 0 {
data.Metadata = cloneAnyMap(auth.Metadata)
} else {
metadata := cloneAnyMap(data.Metadata)
for key, value := range auth.Metadata {
if _, exists := metadata[key]; !exists {
metadata[key] = value
}
}
data.Metadata = metadata
}
if len(data.Attributes) == 0 {
data.Attributes = cloneStringMap(auth.Attributes)
} else {
attributes := cloneStringMap(data.Attributes)
for key, value := range auth.Attributes {
if _, exists := attributes[key]; !exists {
attributes[key] = value
}
}
data.Attributes = attributes
}
if len(data.StorageJSON) == 0 {
data.StorageJSON = storageJSONFromAuth(auth)
}
if data.NextRefreshAfter.IsZero() {
data.NextRefreshAfter = auth.NextRefreshAfter
}
return data
}
type modelClientRegistration struct {
clientID string
provider string
models []*registry.ModelInfo
}
func (h *Host) callModelRegistrar(ctx context.Context, record capabilityRecord, registrar pluginapi.ModelRegistrar) (resp pluginapi.ModelRegistrationResponse, err error) {
if h == nil || registrar == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.ModelRegistrationResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ModelRegistrar.RegisterModels", recovered)
resp = pluginapi.ModelRegistrationResponse{}
err = fmt.Errorf("model registrar panic: %v", recovered)
}
}()
return registrar.RegisterModels(ctx, pluginapi.ModelRegistrationRequest{Plugin: record.meta})
}
func (h *Host) callModelProviderStaticModels(ctx context.Context, record capabilityRecord, provider pluginapi.ModelProvider) (resp pluginapi.ModelResponse, err error) {
if h == nil || provider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.ModelResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ModelProvider.StaticModels", recovered)
resp = pluginapi.ModelResponse{}
err = fmt.Errorf("model provider panic: %v", recovered)
}
}()
return provider.StaticModels(ctx, pluginapi.StaticModelRequest{
Plugin: record.meta,
Host: h.hostConfigSummary(),
})
}
func (h *Host) callModelsForAuth(ctx context.Context, record capabilityRecord, provider pluginapi.ModelProvider, auth *coreauth.Auth) (resp pluginapi.ModelResponse, err error) {
if h == nil || provider == nil || auth == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.ModelResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ModelProvider.ModelsForAuth", recovered)
resp = pluginapi.ModelResponse{}
err = fmt.Errorf("model provider per-auth models panic: %v", recovered)
}
}()
return provider.ModelsForAuth(ctx, pluginapi.AuthModelRequest{
Plugin: record.meta,
AuthID: auth.ID,
AuthProvider: auth.Provider,
StorageJSON: storageJSONFromAuth(auth),
Metadata: cloneAnyMap(auth.Metadata),
Attributes: cloneStringMap(auth.Attributes),
Host: h.hostConfigSummary(),
HTTPClient: h.newHTTPClient(auth),
})
}