mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 06:30:21 +00:00
1766 lines
56 KiB
Go
1766 lines
56 KiB
Go
package jsplugin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"math"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
)
|
|
|
|
const APIVersion1 = 1
|
|
|
|
const (
|
|
maxLocalizedTextLocales = 16
|
|
maxMetaDescriptionRunes = 512
|
|
maxUsageFieldDescriptionRunes = 256
|
|
)
|
|
|
|
var pluginKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
|
var pluginVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
|
|
var localeTagPattern = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`)
|
|
|
|
// LocalizedText is locale-keyed display copy. Plugin source may use a bare
|
|
// string (normalized to {"en": s}) or a map that must include "en". API
|
|
// responses always emit an object.
|
|
type LocalizedText map[string]string
|
|
|
|
func (t LocalizedText) MarshalJSON() ([]byte, error) {
|
|
if t == nil {
|
|
return common.Marshal(map[string]string{})
|
|
}
|
|
return common.Marshal(map[string]string(t))
|
|
}
|
|
|
|
func (t *LocalizedText) UnmarshalJSON(data []byte) error {
|
|
trimmed := strings.TrimSpace(string(data))
|
|
if trimmed == "" || trimmed == "null" {
|
|
*t = nil
|
|
return nil
|
|
}
|
|
switch trimmed[0] {
|
|
case '"':
|
|
var text string
|
|
if err := common.Unmarshal(data, &text); err != nil {
|
|
return err
|
|
}
|
|
*t = LocalizedText{"en": text}
|
|
return nil
|
|
case '{':
|
|
var object map[string]string
|
|
if err := common.Unmarshal(data, &object); err != nil {
|
|
return err
|
|
}
|
|
*t = LocalizedText(object)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("localized text must be a string or object")
|
|
}
|
|
}
|
|
|
|
type Meta struct {
|
|
APIVersion int `json:"apiVersion"`
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon,omitempty"`
|
|
Description LocalizedText `json:"description,omitempty"`
|
|
Version string `json:"version"`
|
|
Author AuthorMeta `json:"author"`
|
|
ChannelTypes []int `json:"channelTypes,omitempty"`
|
|
Models []string `json:"models"`
|
|
FetchMode string `json:"fetchMode"`
|
|
AllowedHosts []string `json:"allowedHosts"`
|
|
Routes []Route `json:"routes"`
|
|
Protocols []ProtocolClaim `json:"protocols"`
|
|
UsageSchema map[string]UsageFieldSchema `json:"usageSchema,omitempty"`
|
|
UsageExamples []UsageExample `json:"usageExamples,omitempty"`
|
|
Auth AuthMeta `json:"auth"`
|
|
}
|
|
|
|
// ProtocolSupports reports whether the named protocol claim includes mode.
|
|
func (m Meta) ProtocolSupports(protocol, mode string) bool {
|
|
for _, claim := range m.Protocols {
|
|
if claim.Name == protocol {
|
|
return slices.Contains(claim.Supports, mode)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UsageExample is a display-only pricing sample: a labeled complete vector
|
|
// over usageSchema. It never participates in billing.
|
|
type UsageExample struct {
|
|
Label string `json:"label"`
|
|
Facts map[string]any `json:"facts"`
|
|
}
|
|
|
|
type AuthorMeta struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
type AuthMeta struct {
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// UsageFieldSchema declares how one usage fact is validated before it can
|
|
// influence billing. Numeric facts use one of the host-owned canonical units;
|
|
// boolean facts are flags; enum facts constrain non-numeric pricing selectors.
|
|
type UsageFieldSchema struct {
|
|
Type string `json:"type,omitempty"`
|
|
Unit string `json:"unit,omitempty"`
|
|
Enum []string `json:"enum,omitempty"`
|
|
Description LocalizedText `json:"description,omitempty"`
|
|
}
|
|
|
|
type LoadedPlugin struct {
|
|
Meta Meta
|
|
Engine *Engine
|
|
}
|
|
|
|
// RegistrySnapshot is a read-only copy of the metadata currently stored in
|
|
// each registry layer.
|
|
type RegistrySnapshot struct {
|
|
Factory []Meta
|
|
Override []Meta
|
|
DisabledFactory []string
|
|
}
|
|
|
|
type PreparedRoutingGeneration struct {
|
|
Generation *RoutingGeneration
|
|
Errors map[string]string
|
|
}
|
|
|
|
type RoutingRebuildOutcome struct {
|
|
Status string `json:"status"`
|
|
AttemptedAt time.Time `json:"attempted_at"`
|
|
Generation uint64 `json:"generation"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type RoutingStatus struct {
|
|
Generation *RoutingGeneration
|
|
LastRebuild RoutingRebuildOutcome
|
|
Errors map[string]string
|
|
}
|
|
|
|
type RoutingGenerationPreparer func(candidate, current *RoutingGeneration) (PreparedRoutingGeneration, error)
|
|
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
factory map[string]*LoadedPlugin
|
|
override map[string]*LoadedPlugin
|
|
activeOverride map[string]*LoadedPlugin
|
|
disabledFactory map[string]struct{}
|
|
masterEnabled atomic.Bool
|
|
overrideEnabled atomic.Bool
|
|
generation atomic.Pointer[RoutingGeneration]
|
|
preparer RoutingGenerationPreparer
|
|
routingErrors map[string]string
|
|
lastRebuildErr string
|
|
lastRebuild RoutingRebuildOutcome
|
|
}
|
|
|
|
func NewRegistry() *Registry {
|
|
registry := &Registry{
|
|
factory: make(map[string]*LoadedPlugin),
|
|
override: make(map[string]*LoadedPlugin),
|
|
activeOverride: make(map[string]*LoadedPlugin),
|
|
routingErrors: make(map[string]string),
|
|
}
|
|
registry.masterEnabled.Store(true)
|
|
registry.overrideEnabled.Store(true)
|
|
generation, _ := buildRoutingGeneration(registry.factory, registry.override, true, 0)
|
|
registry.generation.Store(generation)
|
|
registry.lastRebuild = RoutingRebuildOutcome{
|
|
Status: "success",
|
|
AttemptedAt: generation.PublishedAt,
|
|
Generation: generation.Number,
|
|
}
|
|
return registry
|
|
}
|
|
|
|
var DefaultRegistry = NewRegistry()
|
|
|
|
func (r *Registry) Register(source string, options Options) (*LoadedPlugin, error) {
|
|
return r.register(source, options, false)
|
|
}
|
|
|
|
func (r *Registry) RegisterFactory(source string, options Options) (*LoadedPlugin, error) {
|
|
return r.register(source, options, true)
|
|
}
|
|
|
|
func (r *Registry) register(source string, options Options, factory bool) (*LoadedPlugin, error) {
|
|
plugin, err := CompilePlugin(source, options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
factoryPlugins := clonePluginMap(r.factory)
|
|
overridePlugins := clonePluginMap(r.override)
|
|
if factory {
|
|
factoryPlugins[plugin.Meta.Key] = plugin
|
|
} else {
|
|
overridePlugins[plugin.Meta.Key] = plugin
|
|
}
|
|
enabled := r.overrideEnabled.Load()
|
|
generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(factoryPlugins, r.disabledFactory), overridePlugins, enabled, false, nil)
|
|
if err != nil {
|
|
r.recordRebuildFailure(err)
|
|
return nil, err
|
|
}
|
|
if rejection := routingErrors[plugin.Meta.Key]; rejection != "" {
|
|
r.recordRebuildFailure(errors.New(rejection))
|
|
return nil, fmt.Errorf("%s", rejection)
|
|
}
|
|
r.factory = factoryPlugins
|
|
r.override = overridePlugins
|
|
r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled))
|
|
return plugin, nil
|
|
}
|
|
|
|
// CompilePlugin validates a plugin without publishing it. Callers that refresh
|
|
// multiple plugins use this together with ReplaceOverrides so readers observe a
|
|
// single generation transition.
|
|
func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
|
|
engine, err := Compile(source, options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
value, err := engine.Export(context.Background(), "meta")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta, err := decodeMeta(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = normalizeV1Meta(&meta); err != nil {
|
|
return nil, err
|
|
}
|
|
engine.key = meta.Key
|
|
engine.version = meta.Version
|
|
requiredHooks := []string{"buildSubmitRequest", "parseSubmitResponse", "parseTaskResult"}
|
|
if meta.FetchMode == "batch" {
|
|
requiredHooks = append(requiredHooks, "buildBatchQueryRequest", "parseBatchResult")
|
|
} else {
|
|
requiredHooks = append(requiredHooks, "buildQueryRequest")
|
|
}
|
|
for _, hook := range requiredHooks {
|
|
has, hasErr := engine.HasCallablePath(context.Background(), hook)
|
|
if hasErr != nil {
|
|
return nil, hasErr
|
|
}
|
|
if !has {
|
|
return nil, fmt.Errorf("plugin %s is missing required export %q", meta.Key, hook)
|
|
}
|
|
}
|
|
artifactHooks := make(map[string]bool, 2)
|
|
for _, hook := range []string{"listArtifacts", "buildContentRequest"} {
|
|
exported, exportErr := engine.HasExport(context.Background(), hook)
|
|
if exportErr != nil {
|
|
return nil, exportErr
|
|
}
|
|
if !exported {
|
|
continue
|
|
}
|
|
callable, callableErr := engine.HasCallablePath(context.Background(), hook)
|
|
if callableErr != nil {
|
|
return nil, callableErr
|
|
}
|
|
if !callable {
|
|
return nil, fmt.Errorf("plugin %s export %q is not a function", meta.Key, hook)
|
|
}
|
|
artifactHooks[hook] = true
|
|
}
|
|
if artifactHooks["listArtifacts"] != artifactHooks["buildContentRequest"] {
|
|
return nil, fmt.Errorf("plugin %s must export listArtifacts and buildContentRequest together", meta.Key)
|
|
}
|
|
for _, route := range meta.Routes {
|
|
for kind, member := range map[string]string{"decode": route.Decode, "render": route.Render} {
|
|
if member == "" {
|
|
continue
|
|
}
|
|
has, hasErr := engine.HasCallablePath(context.Background(), "native", member)
|
|
if hasErr != nil {
|
|
return nil, hasErr
|
|
}
|
|
if !has {
|
|
return nil, fmt.Errorf("plugin %s route %s %s references missing native %s %q", meta.Key, route.Method, route.Path, kind, member)
|
|
}
|
|
}
|
|
}
|
|
for _, claim := range meta.Protocols {
|
|
protocol := claim.Name
|
|
definition, _ := HostProtocol(protocol)
|
|
required := make(map[string]struct{})
|
|
allowed := make(map[string]struct{})
|
|
modeHookUsers := make(map[string][]string)
|
|
for _, operation := range definition.Operations {
|
|
for _, hook := range operation.RequiredProtocolMembers {
|
|
required[hook] = struct{}{}
|
|
allowed[hook] = struct{}{}
|
|
}
|
|
for _, mode := range operation.Modes {
|
|
allowed[mode.Hook] = struct{}{}
|
|
if !slices.Contains(modeHookUsers[mode.Hook], mode.Name) {
|
|
modeHookUsers[mode.Hook] = append(modeHookUsers[mode.Hook], mode.Name)
|
|
}
|
|
if slices.Contains(claim.Supports, mode.Name) {
|
|
required[mode.Hook] = struct{}{}
|
|
}
|
|
}
|
|
for _, hook := range operation.RequiredDriverHooks {
|
|
has, hasErr := engine.HasCallablePath(context.Background(), hook)
|
|
if hasErr != nil {
|
|
return nil, hasErr
|
|
}
|
|
if !has {
|
|
return nil, fmt.Errorf("plugin %s protocol %q is missing driver hook %q", meta.Key, protocol, hook)
|
|
}
|
|
}
|
|
}
|
|
requiredHooks := make([]string, 0, len(required))
|
|
for hook := range required {
|
|
requiredHooks = append(requiredHooks, hook)
|
|
}
|
|
sort.Strings(requiredHooks)
|
|
for _, hook := range requiredHooks {
|
|
has, hasErr := engine.HasCallablePath(context.Background(), "protocols", protocol, hook)
|
|
if hasErr != nil {
|
|
return nil, hasErr
|
|
}
|
|
if !has {
|
|
if users := modeHookUsers[hook]; len(users) > 0 {
|
|
mentioned := ""
|
|
for _, name := range claim.Supports {
|
|
if slices.Contains(users, name) {
|
|
mentioned = name
|
|
break
|
|
}
|
|
}
|
|
suggested := make([]string, 0)
|
|
for _, mode := range definition.DefinedModes() {
|
|
exported, exportedErr := engine.HasCallablePath(context.Background(), "protocols", protocol, mode.Hook)
|
|
if exportedErr != nil {
|
|
return nil, exportedErr
|
|
}
|
|
if exported && !slices.Contains(suggested, mode.Name) {
|
|
suggested = append(suggested, mode.Name)
|
|
}
|
|
}
|
|
message := fmt.Sprintf("plugin %s protocol %q supports %q but does not export protocols.%s.%s; implement it", meta.Key, protocol, mentioned, protocol, hook)
|
|
if len(suggested) > 0 {
|
|
message += fmt.Sprintf(" or declare supports: [%s]", quotedJoin(suggested, ", "))
|
|
}
|
|
return nil, errors.New(message)
|
|
}
|
|
return nil, fmt.Errorf("plugin %s protocol %q is missing hook %q", meta.Key, protocol, hook)
|
|
}
|
|
}
|
|
seenModeHook := make(map[string]struct{})
|
|
for _, operation := range definition.Operations {
|
|
for _, mode := range operation.Modes {
|
|
if _, seen := seenModeHook[mode.Hook]; seen {
|
|
continue
|
|
}
|
|
seenModeHook[mode.Hook] = struct{}{}
|
|
if _, need := required[mode.Hook]; need {
|
|
continue
|
|
}
|
|
has, hasErr := engine.HasCallablePath(context.Background(), "protocols", protocol, mode.Hook)
|
|
if hasErr != nil {
|
|
return nil, hasErr
|
|
}
|
|
if has {
|
|
return nil, fmt.Errorf("plugin %s protocol %q exports protocols.%s.%s but no supported mode uses it; add %s to supports or remove the hook", meta.Key, protocol, protocol, mode.Hook, quotedJoin(modeHookUsers[mode.Hook], " or "))
|
|
}
|
|
}
|
|
}
|
|
protocolValue, exportErr := engine.Export(context.Background(), "protocols")
|
|
if exportErr != nil {
|
|
return nil, exportErr
|
|
}
|
|
protocolObject, ok := protocolValue.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin %s export protocols must be an object", meta.Key)
|
|
}
|
|
implementation, ok := protocolObject[protocol].(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin %s protocol %q must be an object", meta.Key, protocol)
|
|
}
|
|
for member := range implementation {
|
|
if _, accepted := allowed[member]; !accepted {
|
|
return nil, fmt.Errorf("plugin %s protocol %q has unsupported member %q", meta.Key, protocol, member)
|
|
}
|
|
}
|
|
}
|
|
if protocolsValue, exportErr := engine.Export(context.Background(), "protocols"); exportErr == nil {
|
|
if protocolsObject, ok := protocolsValue.(map[string]any); ok {
|
|
claimed := make(map[string]struct{}, len(meta.Protocols))
|
|
for _, claim := range meta.Protocols {
|
|
claimed[claim.Name] = struct{}{}
|
|
}
|
|
for name := range protocolsObject {
|
|
if _, ok := claimed[name]; !ok {
|
|
return nil, fmt.Errorf("plugin %s implements unclaimed protocol %q", meta.Key, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, removed := range []string{"resolveRequest", "renderError", "renderers"} {
|
|
has, e := engine.HasExport(context.Background(), removed)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
if has {
|
|
return nil, fmt.Errorf("plugin %s export %q is no longer supported", meta.Key, removed)
|
|
}
|
|
}
|
|
return &LoadedPlugin{Meta: meta, Engine: engine}, nil
|
|
}
|
|
|
|
func (r *Registry) Get(platform string) (*LoadedPlugin, bool) {
|
|
return r.Generation().Get(platform)
|
|
}
|
|
|
|
func (r *Registry) GetByChannelType(channelType int) (*LoadedPlugin, bool) {
|
|
return r.Generation().GetByChannelType(channelType)
|
|
}
|
|
|
|
// Enabled reports the master switch position. When false the published
|
|
// routing generation contains no plugins regardless of the other layers.
|
|
func (r *Registry) Enabled() bool {
|
|
return r.masterEnabled.Load()
|
|
}
|
|
|
|
func (r *Registry) SetEnabled(enabled bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.masterEnabled.Load() == enabled {
|
|
return
|
|
}
|
|
previous := r.masterEnabled.Load()
|
|
r.masterEnabled.Store(enabled)
|
|
overrideEnabled := r.overrideEnabled.Load()
|
|
var retainCurrent map[string]struct{}
|
|
if enabled && overrideEnabled {
|
|
retainCurrent = pluginMapKeys(r.override)
|
|
}
|
|
generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, overrideEnabled, true, retainCurrent)
|
|
if err != nil {
|
|
r.masterEnabled.Store(previous)
|
|
r.recordRebuildFailure(err)
|
|
return
|
|
}
|
|
r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, overrideEnabled))
|
|
}
|
|
|
|
func (r *Registry) SetOverrideEnabled(enabled bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.overrideEnabled.Load() == enabled {
|
|
return
|
|
}
|
|
var retainCurrent map[string]struct{}
|
|
if enabled {
|
|
retainCurrent = pluginMapKeys(r.override)
|
|
}
|
|
generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, enabled, true, retainCurrent)
|
|
if err != nil {
|
|
r.recordRebuildFailure(err)
|
|
return
|
|
}
|
|
r.overrideEnabled.Store(enabled)
|
|
r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled))
|
|
}
|
|
|
|
func (r *Registry) SetDisabledFactoryKeys(keys []string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
next := make(map[string]struct{}, len(keys))
|
|
for _, key := range keys {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
next[key] = struct{}{}
|
|
}
|
|
if len(next) == len(r.disabledFactory) {
|
|
same := true
|
|
for key := range next {
|
|
if _, ok := r.disabledFactory[key]; !ok {
|
|
same = false
|
|
break
|
|
}
|
|
}
|
|
if same {
|
|
return
|
|
}
|
|
}
|
|
|
|
enabled := r.overrideEnabled.Load()
|
|
var retainCurrent map[string]struct{}
|
|
if enabled {
|
|
retainCurrent = pluginMapKeys(r.override)
|
|
}
|
|
generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, next), r.override, enabled, true, retainCurrent)
|
|
if err != nil {
|
|
r.recordRebuildFailure(err)
|
|
return
|
|
}
|
|
r.disabledFactory = next
|
|
r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled))
|
|
}
|
|
|
|
func (r *Registry) Unregister(key string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if _, exists := r.override[key]; !exists {
|
|
return nil
|
|
}
|
|
overridePlugins := clonePluginMap(r.override)
|
|
delete(overridePlugins, key)
|
|
enabled := r.overrideEnabled.Load()
|
|
var retainCurrent map[string]struct{}
|
|
if enabled {
|
|
retainCurrent = pluginMapKeys(overridePlugins)
|
|
}
|
|
generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), overridePlugins, enabled, true, retainCurrent)
|
|
if err != nil {
|
|
r.recordRebuildFailure(err)
|
|
return err
|
|
}
|
|
r.override = overridePlugins
|
|
r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled))
|
|
return nil
|
|
}
|
|
|
|
// ReplaceOverrides atomically publishes a complete override layer.
|
|
func (r *Registry) ReplaceOverrides(plugins []*LoadedPlugin) error {
|
|
overridePlugins := make(map[string]*LoadedPlugin, len(plugins))
|
|
for _, plugin := range plugins {
|
|
if plugin == nil {
|
|
return fmt.Errorf("cannot publish a nil plugin")
|
|
}
|
|
if _, exists := overridePlugins[plugin.Meta.Key]; exists {
|
|
return fmt.Errorf("duplicate override plugin key %q", plugin.Meta.Key)
|
|
}
|
|
overridePlugins[plugin.Meta.Key] = plugin
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if samePluginMap(r.override, overridePlugins) {
|
|
return nil
|
|
}
|
|
enabled := r.overrideEnabled.Load()
|
|
var retainCurrent map[string]struct{}
|
|
if enabled {
|
|
retainCurrent = pluginMapKeys(overridePlugins)
|
|
}
|
|
generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), overridePlugins, enabled, true, retainCurrent)
|
|
if err != nil {
|
|
r.recordRebuildFailure(err)
|
|
return err
|
|
}
|
|
r.override = overridePlugins
|
|
r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, overridePlugins, enabled))
|
|
return nil
|
|
}
|
|
|
|
func (r *Registry) Generation() *RoutingGeneration {
|
|
return r.generation.Load()
|
|
}
|
|
|
|
func (r *Registry) OverridePlugins() map[string]*LoadedPlugin {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return clonePluginMap(r.override)
|
|
}
|
|
|
|
func (r *Registry) ActiveOverridePlugins() map[string]*LoadedPlugin {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return clonePluginMap(r.activeOverride)
|
|
}
|
|
|
|
func (r *Registry) SetGenerationPreparer(preparer RoutingGenerationPreparer) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
previous := r.preparer
|
|
r.preparer = preparer
|
|
enabled := r.overrideEnabled.Load()
|
|
var retainCurrent map[string]struct{}
|
|
if enabled {
|
|
retainCurrent = pluginMapKeys(r.override)
|
|
}
|
|
generation, routingErrors, err := r.prepareGeneration(filterDisabledFactory(r.factory, r.disabledFactory), r.override, enabled, true, retainCurrent)
|
|
if err != nil {
|
|
r.preparer = previous
|
|
r.recordRebuildFailure(err)
|
|
return err
|
|
}
|
|
r.publishGeneration(generation, routingErrors, r.resolveActiveOverrides(generation, r.override, enabled))
|
|
return nil
|
|
}
|
|
|
|
func (r *Registry) RoutingErrors() map[string]string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
errorsCopy := make(map[string]string, len(r.routingErrors))
|
|
maps.Copy(errorsCopy, r.routingErrors)
|
|
return errorsCopy
|
|
}
|
|
|
|
func (r *Registry) LastRebuildError() string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.lastRebuildErr
|
|
}
|
|
|
|
func (r *Registry) LastRebuildOutcome() RoutingRebuildOutcome {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.lastRebuild
|
|
}
|
|
|
|
func (r *Registry) RoutingStatus() RoutingStatus {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
errorsCopy := make(map[string]string, len(r.routingErrors))
|
|
maps.Copy(errorsCopy, r.routingErrors)
|
|
return RoutingStatus{
|
|
Generation: r.generation.Load(),
|
|
LastRebuild: r.lastRebuild,
|
|
Errors: errorsCopy,
|
|
}
|
|
}
|
|
|
|
func (r *Registry) prepareGeneration(
|
|
factory, override map[string]*LoadedPlugin,
|
|
enabled, tolerateConflicts bool,
|
|
retainCurrent map[string]struct{},
|
|
) (*RoutingGeneration, map[string]string, error) {
|
|
if !r.masterEnabled.Load() {
|
|
factory = map[string]*LoadedPlugin{}
|
|
override = map[string]*LoadedPlugin{}
|
|
}
|
|
current := r.generation.Load()
|
|
number := uint64(1)
|
|
if current != nil {
|
|
number = current.Number + 1
|
|
}
|
|
var (
|
|
generation *RoutingGeneration
|
|
routingErrors map[string]string
|
|
err error
|
|
)
|
|
if tolerateConflicts {
|
|
generation, routingErrors, err = buildRoutingGenerationAdmitting(factory, override, enabled, number, current, retainCurrent)
|
|
} else {
|
|
generation, err = buildRoutingGeneration(factory, override, enabled, number)
|
|
routingErrors = make(map[string]string)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !tolerateConflicts {
|
|
// Both runtime switch positions must remain publishable so toggling the
|
|
// override layer never exposes an invalid generation.
|
|
if _, err = buildRoutingGeneration(factory, override, !enabled, number); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
if r.preparer != nil {
|
|
prepared, prepareErr := r.preparer(generation, current)
|
|
if prepareErr != nil {
|
|
return nil, nil, prepareErr
|
|
}
|
|
if prepared.Generation == nil {
|
|
return nil, nil, fmt.Errorf("routing generation preparer returned a nil generation")
|
|
}
|
|
if prepared.Generation.Number != generation.Number {
|
|
return nil, nil, fmt.Errorf("routing generation preparer changed generation number from %d to %d", generation.Number, prepared.Generation.Number)
|
|
}
|
|
generation = prepared.Generation
|
|
maps.Copy(routingErrors, prepared.Errors)
|
|
}
|
|
return generation, routingErrors, nil
|
|
}
|
|
|
|
func (r *Registry) publishGeneration(
|
|
generation *RoutingGeneration,
|
|
routingErrors map[string]string,
|
|
activeOverride map[string]*LoadedPlugin,
|
|
) {
|
|
previous := r.generation.Load()
|
|
var previousNumber uint64
|
|
if previous != nil {
|
|
previousNumber = previous.Number
|
|
}
|
|
r.routingErrors = routingErrors
|
|
r.activeOverride = activeOverride
|
|
r.lastRebuildErr = ""
|
|
status := "success"
|
|
if len(routingErrors) > 0 {
|
|
status = "partial"
|
|
}
|
|
r.lastRebuild = RoutingRebuildOutcome{
|
|
Status: status,
|
|
AttemptedAt: time.Now(),
|
|
Generation: generation.Number,
|
|
}
|
|
r.generation.Store(generation)
|
|
logger.LogDebug(
|
|
context.Background(),
|
|
"task_plugin subsystem=registry event=publish previous_generation=%d generation=%d status=%q plugins=%d routes=%d endpoint_bindings=%d channel_types=%d active_overrides=%d rejected=%d",
|
|
previousNumber,
|
|
generation.Number,
|
|
status,
|
|
len(generation.plugins),
|
|
len(generation.routes),
|
|
len(generation.protocolIndex),
|
|
len(generation.byChannelType),
|
|
len(activeOverride),
|
|
len(routingErrors),
|
|
)
|
|
if len(routingErrors) > 0 {
|
|
keys := make([]string, 0, len(routingErrors))
|
|
for key := range routingErrors {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
logger.LogDebug(
|
|
context.Background(),
|
|
"task_plugin subsystem=registry event=plugin_rejected generation=%d plugin=%q reason=generation_admission_failed admission_reason=%q",
|
|
generation.Number,
|
|
key,
|
|
taskPluginRoutingDebugReason(routingErrors[key]),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Registry) recordRebuildFailure(err error) {
|
|
r.lastRebuildErr = err.Error()
|
|
generation := r.generation.Load()
|
|
var generationNumber uint64
|
|
if generation != nil {
|
|
generationNumber = generation.Number
|
|
}
|
|
r.lastRebuild = RoutingRebuildOutcome{
|
|
Status: "failed",
|
|
AttemptedAt: time.Now(),
|
|
Generation: generationNumber,
|
|
Error: err.Error(),
|
|
}
|
|
logger.LogDebug(
|
|
context.Background(),
|
|
"task_plugin subsystem=registry event=publish_failed retained_generation=%d retained_generation_active=true reason=%q",
|
|
generationNumber,
|
|
taskPluginRoutingDebugReason(err.Error()),
|
|
)
|
|
}
|
|
|
|
func taskPluginRoutingDebugReason(message string) string {
|
|
lower := strings.ToLower(message)
|
|
switch {
|
|
case strings.Contains(lower, "channeltype"), strings.Contains(lower, "channel type"):
|
|
return "channel_type_conflict"
|
|
case strings.Contains(lower, "endpoint"):
|
|
return "endpoint_conflict"
|
|
case strings.Contains(lower, "inner gin"), strings.Contains(lower, "rebuilding public routes"):
|
|
return "inner_router_build_failed"
|
|
case strings.Contains(lower, "trusted prox"):
|
|
return "trusted_proxy_configuration_failed"
|
|
case strings.Contains(lower, "route"):
|
|
return "route_conflict"
|
|
case strings.Contains(lower, "nil generation"), strings.Contains(lower, "generation number"):
|
|
return "invalid_prepared_generation"
|
|
default:
|
|
return "generation_rebuild_failed"
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Snapshot() RegistrySnapshot {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
snapshot := RegistrySnapshot{
|
|
Factory: make([]Meta, 0, len(r.factory)),
|
|
Override: make([]Meta, 0, len(r.override)),
|
|
DisabledFactory: make([]string, 0, len(r.disabledFactory)),
|
|
}
|
|
for _, plugin := range r.factory {
|
|
snapshot.Factory = append(snapshot.Factory, cloneMeta(plugin.Meta))
|
|
}
|
|
for _, plugin := range r.override {
|
|
snapshot.Override = append(snapshot.Override, cloneMeta(plugin.Meta))
|
|
}
|
|
for key := range r.disabledFactory {
|
|
snapshot.DisabledFactory = append(snapshot.DisabledFactory, key)
|
|
}
|
|
sort.Slice(snapshot.Factory, func(i, j int) bool { return snapshot.Factory[i].Key < snapshot.Factory[j].Key })
|
|
sort.Slice(snapshot.Override, func(i, j int) bool { return snapshot.Override[i].Key < snapshot.Override[j].Key })
|
|
sort.Strings(snapshot.DisabledFactory)
|
|
return snapshot
|
|
}
|
|
|
|
func cloneMeta(meta Meta) Meta {
|
|
meta.ChannelTypes = append([]int(nil), meta.ChannelTypes...)
|
|
meta.Models = append([]string(nil), meta.Models...)
|
|
meta.AllowedHosts = append([]string(nil), meta.AllowedHosts...)
|
|
meta.Routes = append([]Route(nil), meta.Routes...)
|
|
for index := range meta.Routes {
|
|
meta.Routes[index].Models = append([]string(nil), meta.Routes[index].Models...)
|
|
}
|
|
meta.Protocols = append([]ProtocolClaim(nil), meta.Protocols...)
|
|
for index := range meta.Protocols {
|
|
meta.Protocols[index].Models = append([]string(nil), meta.Protocols[index].Models...)
|
|
meta.Protocols[index].Supports = append([]string(nil), meta.Protocols[index].Supports...)
|
|
}
|
|
if meta.Description != nil {
|
|
meta.Description = maps.Clone(meta.Description)
|
|
}
|
|
if meta.UsageSchema != nil {
|
|
usageSchema := make(map[string]UsageFieldSchema, len(meta.UsageSchema))
|
|
for key, field := range meta.UsageSchema {
|
|
if field.Enum != nil {
|
|
field.Enum = append([]string{}, field.Enum...)
|
|
}
|
|
if field.Description != nil {
|
|
field.Description = maps.Clone(field.Description)
|
|
}
|
|
usageSchema[key] = field
|
|
}
|
|
meta.UsageSchema = usageSchema
|
|
}
|
|
meta.UsageExamples = cloneUsageExamples(meta.UsageExamples)
|
|
return meta
|
|
}
|
|
|
|
func cloneUsageExamples(examples []UsageExample) []UsageExample {
|
|
if examples == nil {
|
|
return nil
|
|
}
|
|
cloned := make([]UsageExample, len(examples))
|
|
for index, example := range examples {
|
|
cloned[index] = UsageExample{Label: example.Label}
|
|
if example.Facts == nil {
|
|
continue
|
|
}
|
|
facts := make(map[string]any, len(example.Facts))
|
|
maps.Copy(facts, example.Facts)
|
|
cloned[index].Facts = facts
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func filterDisabledFactory(factory map[string]*LoadedPlugin, disabled map[string]struct{}) map[string]*LoadedPlugin {
|
|
if len(disabled) == 0 {
|
|
return factory
|
|
}
|
|
filtered := make(map[string]*LoadedPlugin, len(factory))
|
|
for key, plugin := range factory {
|
|
if _, skip := disabled[key]; skip {
|
|
continue
|
|
}
|
|
filtered[key] = plugin
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func clonePluginMap(source map[string]*LoadedPlugin) map[string]*LoadedPlugin {
|
|
clone := make(map[string]*LoadedPlugin, len(source))
|
|
maps.Copy(clone, source)
|
|
return clone
|
|
}
|
|
|
|
func samePluginMap(left, right map[string]*LoadedPlugin) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for key, plugin := range left {
|
|
if right[key] != plugin {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func pluginMapKeys(plugins map[string]*LoadedPlugin) map[string]struct{} {
|
|
keys := make(map[string]struct{}, len(plugins))
|
|
for key := range plugins {
|
|
keys[key] = struct{}{}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func (r *Registry) resolveActiveOverrides(
|
|
generation *RoutingGeneration,
|
|
override map[string]*LoadedPlugin,
|
|
enabled bool,
|
|
) map[string]*LoadedPlugin {
|
|
active := make(map[string]*LoadedPlugin)
|
|
if !enabled {
|
|
return active
|
|
}
|
|
for _, plugin := range generation.plugins {
|
|
desired, hasOverride := override[plugin.Meta.Key]
|
|
if !hasOverride {
|
|
continue
|
|
}
|
|
if plugin == desired || plugin == r.activeOverride[plugin.Meta.Key] {
|
|
active[plugin.Meta.Key] = plugin
|
|
}
|
|
}
|
|
return active
|
|
}
|
|
|
|
func decodeMeta(value any) (Meta, error) {
|
|
object, ok := value.(map[string]any)
|
|
if !ok {
|
|
return Meta{}, fmt.Errorf("plugin meta must be an object")
|
|
}
|
|
for field := range object {
|
|
switch field {
|
|
case "apiVersion", "key", "name", "icon", "description", "version", "author", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "routes", "protocols", "usageSchema", "usageExamples", "auth", "endpoints", "submitPaths", "actions":
|
|
default:
|
|
return Meta{}, fmt.Errorf("plugin meta has unknown field %q", field)
|
|
}
|
|
}
|
|
meta := Meta{}
|
|
var err error
|
|
meta.APIVersion, err = integerMetaField(object, "apiVersion")
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if meta.Key, err = stringMetaField(object, "key"); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if meta.Name, err = stringMetaField(object, "name"); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if meta.Icon, err = stringMetaField(object, "icon"); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
meta.Icon = strings.TrimSpace(meta.Icon)
|
|
if meta.Description, err = localizedTextMetaField(object, "description", maxMetaDescriptionRunes); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if meta.Version, err = stringMetaField(object, "version"); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
author, ok := object["author"].(map[string]any)
|
|
if !ok {
|
|
return Meta{}, fmt.Errorf("plugin meta author must be an object")
|
|
}
|
|
for field := range author {
|
|
if field != "name" && field != "url" {
|
|
return Meta{}, fmt.Errorf("plugin meta author has unknown field %q", field)
|
|
}
|
|
}
|
|
if meta.Author.Name, err = stringMetaField(author, "name"); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if rawURL, exists := author["url"]; exists {
|
|
meta.Author.URL, ok = rawURL.(string)
|
|
if !ok {
|
|
return Meta{}, fmt.Errorf("plugin meta author field %q must be a string", "url")
|
|
}
|
|
}
|
|
if _, exists := object["channelType"]; exists {
|
|
return Meta{}, fmt.Errorf("plugin meta channelType is no longer supported; declare channelTypes instead")
|
|
}
|
|
if _, exists := object["compatibleChannelTypes"]; exists {
|
|
return Meta{}, fmt.Errorf("plugin meta compatibleChannelTypes is no longer supported; declare channelTypes instead")
|
|
}
|
|
meta.ChannelTypes, err = integerSliceMetaField(object, "channelTypes")
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if meta.FetchMode, err = stringMetaField(object, "fetchMode"); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
meta.Models, err = strictStringSlice(object, "models")
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
meta.AllowedHosts, err = strictStringSlice(object, "allowedHosts")
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
meta.Routes, err = decodeRoutes(object["routes"])
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if _, exists := object["endpoints"]; exists {
|
|
return Meta{}, fmt.Errorf("plugin meta endpoints is no longer supported; declare protocols by name")
|
|
}
|
|
meta.Protocols, err = decodeProtocolClaims(object, "protocols")
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if usageSchema, exists := object["usageSchema"]; exists {
|
|
meta.UsageSchema, err = decodeUsageSchema(usageSchema)
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
}
|
|
if usageExamples, exists := object["usageExamples"]; exists {
|
|
meta.UsageExamples, err = decodeUsageExamples(usageExamples)
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
}
|
|
for _, removedField := range []string{"submitPaths", "actions"} {
|
|
if _, exists := object[removedField]; exists {
|
|
return Meta{}, fmt.Errorf("plugin meta %s is no longer supported; declare routes instead", removedField)
|
|
}
|
|
}
|
|
switch auth := object["auth"].(type) {
|
|
case nil:
|
|
case string:
|
|
meta.Auth.Type = auth
|
|
case map[string]any:
|
|
for key := range auth {
|
|
if key != "type" {
|
|
return Meta{}, fmt.Errorf("plugin meta auth has unknown field %q", key)
|
|
}
|
|
}
|
|
meta.Auth.Type, err = stringMetaField(auth, "type")
|
|
if err != nil {
|
|
return Meta{}, err
|
|
}
|
|
default:
|
|
return Meta{}, fmt.Errorf("plugin meta auth must be a string or object")
|
|
}
|
|
meta.Auth.Type = strings.TrimSpace(meta.Auth.Type)
|
|
if meta.Auth.Type == "vertex_oauth" {
|
|
meta.Auth.Type = "oauth2_jwt"
|
|
}
|
|
if meta.Auth.Type != "" && meta.Auth.Type != "none" && meta.Auth.Type != "api_key" && meta.Auth.Type != "oauth2_jwt" {
|
|
return Meta{}, fmt.Errorf("unsupported plugin auth type %q", meta.Auth.Type)
|
|
}
|
|
if meta.APIVersion != APIVersion1 {
|
|
return Meta{}, fmt.Errorf("unsupported plugin apiVersion %d", meta.APIVersion)
|
|
}
|
|
if strings.TrimSpace(meta.Key) == "" || strings.TrimSpace(meta.Name) == "" || strings.TrimSpace(meta.Version) == "" {
|
|
return Meta{}, fmt.Errorf("plugin meta key, name, and version are required")
|
|
}
|
|
if len(meta.Key) > 30 {
|
|
return Meta{}, fmt.Errorf("plugin meta key must not exceed 30 characters")
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
// ValidateV1Meta applies the metadata constraints published in
|
|
// docs/plugin-api/v1.schema.json to administrator uploads.
|
|
func ValidateV1Meta(meta Meta) error {
|
|
meta = cloneMeta(meta)
|
|
return normalizeV1Meta(&meta)
|
|
}
|
|
|
|
func normalizeV1Meta(meta *Meta) error {
|
|
if meta.APIVersion != APIVersion1 {
|
|
return fmt.Errorf("unsupported plugin apiVersion %d", meta.APIVersion)
|
|
}
|
|
if strings.TrimSpace(meta.Name) == "" {
|
|
return fmt.Errorf("plugin meta name is required")
|
|
}
|
|
meta.Icon = strings.TrimSpace(meta.Icon)
|
|
if meta.Icon != "" {
|
|
if utf8.RuneCountInString(meta.Icon) > 128 {
|
|
return fmt.Errorf("plugin meta icon must not exceed 128 characters")
|
|
}
|
|
for _, character := range meta.Icon {
|
|
if unicode.IsControl(character) {
|
|
return fmt.Errorf("plugin meta icon must not contain control characters")
|
|
}
|
|
}
|
|
}
|
|
if err := validateLocalizedText(meta.Description, "description", maxMetaDescriptionRunes); err != nil {
|
|
return err
|
|
}
|
|
meta.Author.Name = strings.TrimSpace(meta.Author.Name)
|
|
if meta.Author.Name == "" {
|
|
return fmt.Errorf("plugin meta author name is required")
|
|
}
|
|
meta.Author.URL = strings.TrimSpace(meta.Author.URL)
|
|
if meta.Author.URL != "" {
|
|
parsedURL, err := url.Parse(meta.Author.URL)
|
|
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
|
|
return fmt.Errorf("plugin meta author url must be an absolute HTTP(S) URL")
|
|
}
|
|
}
|
|
if !pluginKeyPattern.MatchString(meta.Key) {
|
|
return fmt.Errorf("plugin meta key must match %s", pluginKeyPattern)
|
|
}
|
|
if len(meta.Key) > 30 {
|
|
return fmt.Errorf("plugin meta key must not exceed 30 characters")
|
|
}
|
|
if !pluginVersionPattern.MatchString(meta.Version) {
|
|
return fmt.Errorf("plugin meta version must be semver")
|
|
}
|
|
if meta.FetchMode != "per_task" && meta.FetchMode != "batch" {
|
|
return fmt.Errorf("plugin meta fetchMode must be per_task or batch")
|
|
}
|
|
if len(meta.Models) == 0 {
|
|
return fmt.Errorf("plugin meta models must contain at least one model")
|
|
}
|
|
seenChannelTypes := make(map[int]struct{}, len(meta.ChannelTypes))
|
|
for _, channelType := range meta.ChannelTypes {
|
|
if channelType <= 0 {
|
|
return fmt.Errorf("plugin meta channelTypes must contain positive channel types")
|
|
}
|
|
if channelType == constant.ChannelTypeTaskPlugin {
|
|
return fmt.Errorf("plugin meta channelTypes must not contain the task plugin channel type")
|
|
}
|
|
if _, duplicate := seenChannelTypes[channelType]; duplicate {
|
|
return fmt.Errorf("plugin meta channelTypes must be unique")
|
|
}
|
|
seenChannelTypes[channelType] = struct{}{}
|
|
}
|
|
models := make(map[string]struct{}, len(meta.Models))
|
|
for _, model := range meta.Models {
|
|
if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model {
|
|
return fmt.Errorf("plugin meta models must contain non-empty canonical names")
|
|
}
|
|
if _, exists := models[model]; exists {
|
|
return fmt.Errorf("plugin meta models must be unique")
|
|
}
|
|
models[model] = struct{}{}
|
|
}
|
|
hosts := make(map[string]struct{}, len(meta.AllowedHosts))
|
|
for _, host := range meta.AllowedHosts {
|
|
if strings.TrimSpace(host) == "" || strings.ContainsAny(host, "/:?#") {
|
|
return fmt.Errorf("plugin meta allowedHosts must contain hostnames without schemes, ports, or paths")
|
|
}
|
|
if _, exists := hosts[host]; exists {
|
|
return fmt.Errorf("plugin meta allowedHosts must be unique")
|
|
}
|
|
hosts[host] = struct{}{}
|
|
}
|
|
routeKeys := make(map[string]struct{}, len(meta.Routes))
|
|
for index := range meta.Routes {
|
|
if err := validateRoute(&meta.Routes[index]); err != nil {
|
|
return err
|
|
}
|
|
for _, model := range meta.Routes[index].Models {
|
|
if _, exists := models[model]; !exists {
|
|
return fmt.Errorf("plugin route %s %s model %q is not declared in plugin meta models", meta.Routes[index].Method, meta.Routes[index].Path, model)
|
|
}
|
|
}
|
|
shape, err := routePathShape(meta.Routes[index].Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := meta.Routes[index].Method + " " + shape
|
|
if _, exists := routeKeys[key]; exists {
|
|
return fmt.Errorf("plugin meta routes contain duplicate route %s %s", meta.Routes[index].Method, meta.Routes[index].Path)
|
|
}
|
|
routeKeys[key] = struct{}{}
|
|
}
|
|
protocols := make(map[string]struct{}, len(meta.Protocols))
|
|
for index := range meta.Protocols {
|
|
claim := &meta.Protocols[index]
|
|
definition, known := HostProtocol(claim.Name)
|
|
modes := definition.DefinedModes()
|
|
if len(modes) > 0 {
|
|
modeNames := make([]string, len(modes))
|
|
for modeIndex, mode := range modes {
|
|
modeNames[modeIndex] = mode.Name
|
|
}
|
|
choosingFrom := quotedJoin(modeNames, ", ")
|
|
if claim.Supports == nil {
|
|
if claim.objectForm {
|
|
return fmt.Errorf("plugin %s protocol %q must declare supports; add supports: [...] choosing from %s", meta.Key, claim.Name, choosingFrom)
|
|
}
|
|
return fmt.Errorf("plugin %s protocol %q must declare supports; replace the bare string with {name: %q, supports: [...]} choosing from %s", meta.Key, claim.Name, claim.Name, choosingFrom)
|
|
}
|
|
if len(claim.Supports) == 0 {
|
|
return fmt.Errorf("plugin %s protocol %q supports must contain at least one of %s", meta.Key, claim.Name, choosingFrom)
|
|
}
|
|
seenSupports := make(map[string]struct{}, len(claim.Supports))
|
|
for _, support := range claim.Supports {
|
|
if _, duplicate := seenSupports[support]; duplicate {
|
|
return fmt.Errorf("plugin %s protocol %q supports must be unique", meta.Key, claim.Name)
|
|
}
|
|
seenSupports[support] = struct{}{}
|
|
if !slices.Contains(modeNames, support) {
|
|
if support == "retrieve" {
|
|
return fmt.Errorf("plugin %s protocol %q has no mode %q; retrieval of a created response is always available and is never declared", meta.Key, claim.Name, support)
|
|
}
|
|
return fmt.Errorf("plugin %s protocol %q has no mode %q", meta.Key, claim.Name, support)
|
|
}
|
|
}
|
|
claim.Supports = orderProtocolSupports(claim.Name, claim.Supports)
|
|
} else if claim.Supports != nil {
|
|
return fmt.Errorf("plugin %s protocol %q does not define modes; supports is not allowed", meta.Key, claim.Name)
|
|
}
|
|
if !known {
|
|
return fmt.Errorf("plugin meta protocol %q is unknown", claim.Name)
|
|
}
|
|
if _, duplicate := protocols[claim.Name]; duplicate {
|
|
return fmt.Errorf("plugin meta protocols must be unique")
|
|
}
|
|
protocols[claim.Name] = struct{}{}
|
|
if err := validateModelScope(claim.Models, fmt.Sprintf("protocol %q", claim.Name)); err != nil {
|
|
return err
|
|
}
|
|
for _, model := range claim.Models {
|
|
if _, exists := models[model]; !exists {
|
|
return fmt.Errorf("plugin protocol %q model %q is not declared in plugin meta models", claim.Name, model)
|
|
}
|
|
}
|
|
}
|
|
for name, field := range meta.UsageSchema {
|
|
if strings.TrimSpace(name) == "" || strings.TrimSpace(name) != name {
|
|
return fmt.Errorf("plugin meta usageSchema keys must be non-empty canonical names")
|
|
}
|
|
if err := validateUsageFieldSchema(name, field); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := validateUsageExamples(meta.UsageSchema, meta.UsageExamples); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) {
|
|
if value == nil {
|
|
return nil, fmt.Errorf("plugin meta usageSchema must be an object")
|
|
}
|
|
object, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta usageSchema must be an object")
|
|
}
|
|
schema := make(map[string]UsageFieldSchema, len(object))
|
|
for name, rawField := range object {
|
|
fieldObject, ok := rawField.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta usageSchema field %q must be an object", name)
|
|
}
|
|
for key := range fieldObject {
|
|
switch key {
|
|
case "type", "unit", "enum", "description":
|
|
default:
|
|
return nil, fmt.Errorf("plugin meta usageSchema field %q has unknown property %q", name, key)
|
|
}
|
|
}
|
|
field := UsageFieldSchema{}
|
|
var err error
|
|
if field.Type, err = stringMetaField(fieldObject, "type"); err != nil {
|
|
return nil, err
|
|
}
|
|
if field.Unit, err = stringMetaField(fieldObject, "unit"); err != nil {
|
|
return nil, err
|
|
}
|
|
if field.Description, err = localizedTextMetaField(fieldObject, "description", maxUsageFieldDescriptionRunes); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, exists := fieldObject["enum"]; exists {
|
|
if field.Enum, err = strictStringSlice(fieldObject, "enum"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err = validateUsageFieldSchema(name, field); err != nil {
|
|
return nil, err
|
|
}
|
|
schema[name] = field
|
|
}
|
|
return schema, nil
|
|
}
|
|
|
|
func validateUsageFieldSchema(name string, field UsageFieldSchema) error {
|
|
if err := validateLocalizedText(field.Description, fmt.Sprintf("usageSchema field %q description", name), maxUsageFieldDescriptionRunes); err != nil {
|
|
return err
|
|
}
|
|
if field.Enum != nil {
|
|
if field.Type != "" || field.Unit != "" {
|
|
return fmt.Errorf("plugin meta usageSchema field %q cannot combine enum with type or unit", name)
|
|
}
|
|
if len(field.Enum) == 0 {
|
|
return fmt.Errorf("plugin meta usageSchema field %q enum must contain at least one value", name)
|
|
}
|
|
values := make(map[string]struct{}, len(field.Enum))
|
|
for _, value := range field.Enum {
|
|
if _, exists := values[value]; exists {
|
|
return fmt.Errorf("plugin meta usageSchema field %q enum values must be unique", name)
|
|
}
|
|
values[value] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
if field.Type == "boolean" {
|
|
if field.Unit != "" {
|
|
return fmt.Errorf("plugin meta usageSchema field %q cannot combine boolean with unit", name)
|
|
}
|
|
return nil
|
|
}
|
|
if field.Type != "number" {
|
|
return fmt.Errorf("plugin meta usageSchema field %q type must be number or boolean", name)
|
|
}
|
|
if field.Unit != "second" && field.Unit != "count" && field.Unit != "token" && field.Unit != "credit" {
|
|
return fmt.Errorf("plugin meta usageSchema field %q unit must be second, count, token, or credit", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const maxUsageExamples = 16
|
|
const maxUsageExampleLabelRunes = 48
|
|
|
|
func decodeUsageExamples(value any) ([]UsageExample, error) {
|
|
if value == nil {
|
|
return nil, fmt.Errorf("plugin meta usageExamples must be an array")
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta usageExamples must be an array")
|
|
}
|
|
if len(items) > maxUsageExamples {
|
|
return nil, fmt.Errorf("plugin meta usageExamples must not exceed %d entries", maxUsageExamples)
|
|
}
|
|
examples := make([]UsageExample, 0, len(items))
|
|
for index, item := range items {
|
|
object, ok := item.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta usageExamples[%d] must be an object", index)
|
|
}
|
|
for key := range object {
|
|
if key != "label" && key != "facts" {
|
|
return nil, fmt.Errorf("plugin meta usageExamples[%d] has unknown field %q", index, key)
|
|
}
|
|
}
|
|
label, err := stringMetaField(object, "label")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plugin meta usageExamples[%d] %w", index, err)
|
|
}
|
|
rawFacts, exists := object["facts"]
|
|
if !exists || rawFacts == nil {
|
|
return nil, fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index)
|
|
}
|
|
facts, ok := rawFacts.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index)
|
|
}
|
|
examples = append(examples, UsageExample{Label: label, Facts: facts})
|
|
}
|
|
return examples, nil
|
|
}
|
|
|
|
func usageSchemaHasTokenUnit(schema map[string]UsageFieldSchema) bool {
|
|
for _, field := range schema {
|
|
if field.Type == "number" && field.Unit == "token" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validateUsageExamples(schema map[string]UsageFieldSchema, examples []UsageExample) error {
|
|
if len(examples) == 0 {
|
|
if usageSchemaHasTokenUnit(schema) {
|
|
return fmt.Errorf("plugin meta usageExamples is required when usageSchema declares a token unit")
|
|
}
|
|
return nil
|
|
}
|
|
if len(schema) == 0 {
|
|
return fmt.Errorf("plugin meta usageExamples requires usageSchema")
|
|
}
|
|
if len(examples) > maxUsageExamples {
|
|
return fmt.Errorf("plugin meta usageExamples must not exceed %d entries", maxUsageExamples)
|
|
}
|
|
for index := range examples {
|
|
label := strings.TrimSpace(examples[index].Label)
|
|
if label == "" {
|
|
return fmt.Errorf("plugin meta usageExamples[%d] label is required", index)
|
|
}
|
|
if utf8.RuneCountInString(label) > maxUsageExampleLabelRunes {
|
|
return fmt.Errorf("plugin meta usageExamples[%d] label must not exceed %d characters", index, maxUsageExampleLabelRunes)
|
|
}
|
|
examples[index].Label = label
|
|
if examples[index].Facts == nil {
|
|
return fmt.Errorf("plugin meta usageExamples[%d] facts must be an object", index)
|
|
}
|
|
for key := range schema {
|
|
if _, exists := examples[index].Facts[key]; !exists {
|
|
return fmt.Errorf("plugin meta usageExamples[%d] facts missing key %q", index, key)
|
|
}
|
|
}
|
|
for key, value := range examples[index].Facts {
|
|
field, declared := schema[key]
|
|
if !declared {
|
|
return fmt.Errorf("plugin meta usageExamples[%d] facts has undeclared key %q", index, key)
|
|
}
|
|
if err := validateUsageExampleValue(value, field); err != nil {
|
|
return fmt.Errorf("plugin meta usageExamples[%d] facts field %q %s", index, key, err.Error())
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateUsageExampleValue(value any, field UsageFieldSchema) error {
|
|
if len(field.Enum) > 0 {
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
return fmt.Errorf("enum is not an allowed value")
|
|
}
|
|
if slices.Contains(field.Enum, text) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("enum is not an allowed value")
|
|
}
|
|
if field.Type == "boolean" {
|
|
if _, ok := value.(bool); !ok {
|
|
return fmt.Errorf("must be a boolean")
|
|
}
|
|
return nil
|
|
}
|
|
number, ok := usageExampleNumber(value)
|
|
if !ok {
|
|
return fmt.Errorf("must be a finite non-negative number")
|
|
}
|
|
if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
|
|
return fmt.Errorf("must be a finite non-negative number")
|
|
}
|
|
limit := float64(relaycommon.MaxTaskDurationSeconds)
|
|
if field.Unit == "count" {
|
|
limit = float64(dto.MaxImageN)
|
|
} else if field.Unit == "token" || field.Unit == "credit" {
|
|
limit = float64(common.MaxQuota)
|
|
}
|
|
if number > limit {
|
|
return fmt.Errorf("exceeds the host limit")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func usageExampleNumber(value any) (float64, bool) {
|
|
switch number := value.(type) {
|
|
case float64:
|
|
return number, true
|
|
case int64:
|
|
return float64(number), true
|
|
case int:
|
|
return float64(number), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func decodeRoutes(value any) ([]Route, error) {
|
|
if value == nil {
|
|
return []Route{}, nil
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta routes must be an array")
|
|
}
|
|
routes := make([]Route, 0, len(items))
|
|
for index, item := range items {
|
|
object, ok := item.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta route %d must be an object", index)
|
|
}
|
|
if _, exists := object["renderer"]; exists {
|
|
return nil, fmt.Errorf("plugin meta route %d field renderer is no longer supported", index)
|
|
}
|
|
for key := range object {
|
|
switch key {
|
|
case "method", "path", "type", "action", "decode", "render", "taskIdParam", "models":
|
|
default:
|
|
return nil, fmt.Errorf("plugin meta route %d has unknown field %q", index, key)
|
|
}
|
|
}
|
|
route := Route{}
|
|
var err error
|
|
if route.Method, err = stringMetaField(object, "method"); err != nil {
|
|
return nil, err
|
|
}
|
|
if route.Path, err = stringMetaField(object, "path"); err != nil {
|
|
return nil, err
|
|
}
|
|
routeType, err := stringMetaField(object, "type")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
route.Type = RouteType(routeType)
|
|
if route.Action, err = stringMetaField(object, "action"); err != nil {
|
|
return nil, err
|
|
}
|
|
if route.Decode, err = stringMetaField(object, "decode"); err != nil {
|
|
return nil, err
|
|
}
|
|
if route.Render, err = stringMetaField(object, "render"); err != nil {
|
|
return nil, err
|
|
}
|
|
if route.TaskIDParam, err = stringMetaField(object, "taskIdParam"); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, exists := object["models"]; exists {
|
|
if route.Models, err = strictStringSlice(object, "models"); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(route.Models) == 0 {
|
|
return nil, fmt.Errorf("plugin meta route %d models must contain at least one model", index)
|
|
}
|
|
}
|
|
routes = append(routes, route)
|
|
}
|
|
return routes, nil
|
|
}
|
|
|
|
// decodeProtocolClaims accepts both protocol entry shapes: a bare protocol
|
|
// name string (binds every meta.models entry) and an object {name, models,
|
|
// supports}. An absent key is empty; a present null or non-array is rejected.
|
|
// The supports key is decoded only when present so an absent key stays nil.
|
|
func decodeProtocolClaims(object map[string]any, name string) ([]ProtocolClaim, error) {
|
|
value, exists := object[name]
|
|
if !exists {
|
|
return []ProtocolClaim{}, nil
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta %s must be an array", name)
|
|
}
|
|
claims := make([]ProtocolClaim, 0, len(items))
|
|
for index, item := range items {
|
|
switch entry := item.(type) {
|
|
case string:
|
|
claims = append(claims, ProtocolClaim{Name: entry})
|
|
case map[string]any:
|
|
for key := range entry {
|
|
switch key {
|
|
case "name", "models", "supports":
|
|
default:
|
|
return nil, fmt.Errorf("plugin meta protocol %d has unknown field %q", index, key)
|
|
}
|
|
}
|
|
claim := ProtocolClaim{objectForm: true}
|
|
var err error
|
|
if claim.Name, err = stringMetaField(entry, "name"); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, exists := entry["models"]; exists {
|
|
if claim.Models, err = strictStringSlice(entry, "models"); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(claim.Models) == 0 {
|
|
return nil, fmt.Errorf("plugin meta protocol %d models must contain at least one model", index)
|
|
}
|
|
}
|
|
if _, exists := entry["supports"]; exists {
|
|
if claim.Supports, err = strictStringSlice(entry, "supports"); err != nil {
|
|
return nil, err
|
|
}
|
|
claim.Supports = orderProtocolSupports(claim.Name, claim.Supports)
|
|
}
|
|
claims = append(claims, claim)
|
|
default:
|
|
return nil, fmt.Errorf("plugin meta protocol %d must be a string or an object", index)
|
|
}
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
func integerMetaField(object map[string]any, name string) (int, error) {
|
|
value, exists := object[name]
|
|
if !exists {
|
|
return 0, nil
|
|
}
|
|
switch number := value.(type) {
|
|
case int64:
|
|
converted := int(number)
|
|
if int64(converted) != number {
|
|
return 0, fmt.Errorf("plugin meta %s is outside the supported integer range", name)
|
|
}
|
|
return converted, nil
|
|
case float64:
|
|
if math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number {
|
|
return 0, fmt.Errorf("plugin meta %s must be an integer", name)
|
|
}
|
|
converted := int(number)
|
|
if float64(converted) != number {
|
|
return 0, fmt.Errorf("plugin meta %s is outside the supported integer range", name)
|
|
}
|
|
return converted, nil
|
|
default:
|
|
return 0, fmt.Errorf("plugin meta %s must be an integer", name)
|
|
}
|
|
}
|
|
|
|
func integerSliceMetaField(object map[string]any, name string) ([]int, error) {
|
|
value, exists := object[name]
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta %s must be an array of integers", name)
|
|
}
|
|
numbers := make([]int, 0, len(items))
|
|
for index, item := range items {
|
|
element := map[string]any{name: item}
|
|
number, err := integerMetaField(element, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plugin meta %s element %d must be an integer", name, index+1)
|
|
}
|
|
numbers = append(numbers, number)
|
|
}
|
|
return numbers, nil
|
|
}
|
|
|
|
func stringMetaField(object map[string]any, name string) (string, error) {
|
|
value, exists := object[name]
|
|
if !exists {
|
|
return "", nil
|
|
}
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("plugin meta %s must be a string", name)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
func localizedTextMetaField(object map[string]any, name string, maxRunes int) (LocalizedText, error) {
|
|
value, exists := object[name]
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
var text LocalizedText
|
|
switch typed := value.(type) {
|
|
case string:
|
|
text = LocalizedText{"en": typed}
|
|
case map[string]any:
|
|
text = make(LocalizedText, len(typed))
|
|
for locale, raw := range typed {
|
|
item, ok := raw.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta %s locale %q must be a string", name, locale)
|
|
}
|
|
text[locale] = item
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("plugin meta %s must be a string or object", name)
|
|
}
|
|
if err := validateLocalizedText(text, name, maxRunes); err != nil {
|
|
return nil, err
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
func validateLocalizedText(text LocalizedText, name string, maxRunes int) error {
|
|
if text == nil {
|
|
return nil
|
|
}
|
|
if len(text) > maxLocalizedTextLocales {
|
|
return fmt.Errorf("plugin meta %s must not exceed %d locales", name, maxLocalizedTextLocales)
|
|
}
|
|
canonical := make(map[string]string, len(text))
|
|
for locale, value := range text {
|
|
if !localeTagPattern.MatchString(locale) {
|
|
return fmt.Errorf("plugin meta %s has invalid locale %q", name, locale)
|
|
}
|
|
canonicalLocale := canonicalLocaleTag(locale)
|
|
if _, duplicate := canonical[canonicalLocale]; duplicate {
|
|
return fmt.Errorf("plugin meta %s has duplicate locale %q", name, canonicalLocale)
|
|
}
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("plugin meta %s value for %q must be a non-empty string", name, locale)
|
|
}
|
|
for _, character := range trimmed {
|
|
if unicode.IsControl(character) {
|
|
return fmt.Errorf("plugin meta %s value for %q must not contain control characters", name, locale)
|
|
}
|
|
}
|
|
if utf8.RuneCountInString(trimmed) > maxRunes {
|
|
return fmt.Errorf("plugin meta %s must not exceed %d characters", name, maxRunes)
|
|
}
|
|
canonical[canonicalLocale] = trimmed
|
|
}
|
|
if strings.TrimSpace(canonical["en"]) == "" {
|
|
return fmt.Errorf("plugin meta %s must include a non-empty \"en\" value", name)
|
|
}
|
|
for locale := range text {
|
|
delete(text, locale)
|
|
}
|
|
for locale, value := range canonical {
|
|
text[locale] = value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// canonicalLocaleTag applies BCP-47 case conventions so lookups can use
|
|
// exact matching: language lowercase, 2-letter region uppercase, 4-letter
|
|
// script title case (zh-tw -> zh-TW, EN -> en, zh-hans -> zh-Hans).
|
|
func canonicalLocaleTag(tag string) string {
|
|
parts := strings.Split(tag, "-")
|
|
parts[0] = strings.ToLower(parts[0])
|
|
for index := 1; index < len(parts); index++ {
|
|
switch len(parts[index]) {
|
|
case 2:
|
|
parts[index] = strings.ToUpper(parts[index])
|
|
case 4:
|
|
lowered := strings.ToLower(parts[index])
|
|
parts[index] = strings.ToUpper(lowered[:1]) + lowered[1:]
|
|
default:
|
|
parts[index] = strings.ToLower(parts[index])
|
|
}
|
|
}
|
|
return strings.Join(parts, "-")
|
|
}
|
|
|
|
func quotedJoin(items []string, sep string) string {
|
|
parts := make([]string, len(items))
|
|
for index, item := range items {
|
|
parts[index] = fmt.Sprintf("%q", item)
|
|
}
|
|
return strings.Join(parts, sep)
|
|
}
|
|
|
|
func strictStringSlice(object map[string]any, name string) ([]string, error) {
|
|
value, exists := object[name]
|
|
if !exists {
|
|
return []string{}, nil
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta %s must be an array of strings", name)
|
|
}
|
|
result := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
text, ok := item.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin meta %s must be an array of strings", name)
|
|
}
|
|
result = append(result, text)
|
|
}
|
|
return result, nil
|
|
}
|