501 lines
16 KiB
Go
501 lines
16 KiB
Go
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),
|
|
})
|
|
}
|