mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-27 10:04:02 +08:00
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.
510 lines
16 KiB
Go
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 ®istry.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 ®istry.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, ©Model)
|
|
}
|
|
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),
|
|
})
|
|
}
|