package pluginhost import ( "context" "fmt" "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" _ "github.com/router-for-me/CLIProxyAPI/v7/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, 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, 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 } func (h *Host) RegisterModels(ctx context.Context, modelRegistry modelRegistry) { if h == nil || modelRegistry == nil { return } snap := h.Snapshot() records := h.activeRecordsFromSnapshot(snap) registrations := make([]modelClientRegistration, 0) nextClients := make(map[string]struct{}) 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 if record.plugin.Capabilities.Executor == nil { clientID := "plugin:" + record.id + ":" + provider registrations = append(registrations, modelClientRegistration{ clientID: clientID, provider: provider, models: models, }) nextClients[clientID] = struct{}{} } } h.commitModelClients(snap, modelRegistry, registrations, nextClients, nextProviders, nextModelRegistrations) } 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() { modelProvider := record.plugin.Capabilities.ModelProvider if modelProvider == nil || h.isPluginFused(record.id) { continue } if !executorScopeAllowsOAuthModels(record.plugin.Capabilities) { continue } authProvider := record.plugin.Capabilities.AuthProvider if authProvider != nil { identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, authProvider) if !okIdentifier || normalizeProviderID(identifier) != providerKey { continue } } else { recordProvider := normalizeProviderID(h.modelProvider(record.id)) if recordProvider == "" { executor := record.plugin.Capabilities.Executor if executor != nil { candidate, okCandidate := h.executorProvider(record, executor) if okCandidate { recordProvider = candidate } } } if recordProvider != providerKey { continue } } resp, errModels := h.callModelsForAuth(ctx, record, 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), }) }