Files
new-api/pkg/jsplugin/registry.go
T
CaIon 6c22550ea3 feat(task): resolve channel-mapped aliases and case variants for plugin models
Channel model_mapping keys exposed in a channel's model list now act as
first-class aliases for task-plugin models across the whole line:

- Derived alias view (model/task_model_alias.go): built from enabled
  channels' model_mapping, chain-following with cycle detection, declared
  names always win, cross-plugin conflicts dropped. Rebuilt on channel
  cache refresh, registry generation change, and a 60s TTL.
- Request path: PinTaskPluginEndpoint resolves declared-name case folds
  and mapping aliases before endpoint lookup (never rewriting the body
  until the endpoint is claimed), pins with MappedModel, and the decode
  contract accepts alias echoes without loosening model ownership for
  normal pins. Legacy /v1/tasks submit folds case variants the same way.
  Fixes aliases on POST /v1/responses silently falling through to the
  main relay against task channels.
- Mapping order: ModelMappedHelper now runs before the plugin submit
  hook builds and caches the upstream body, so channel model_mapping
  actually reaches the upstream request. Plugins receive the mapped
  name as ctx.upstreamModel in both decode and submit contexts.
- Billing: identity stays the origin name; when the alias has no tiered
  expression, the selected channel's mapping tail expression applies.
  Pricing page and billing-expr smoke tests resolve aliases to the
  owning plugin's usage schema.
- Case folding: ASCII-only fold with exact-match priority; same-plugin
  and cross-plugin fold collisions rejected at registration.
- Plugins: model-keyed rate tables, req_key derivation, and combo
  validation in doubao/kling/jimeng/hailuo/vidu/sunoapi now key on
  ctx.upstreamModel || ctx.model; render/echo paths keep ctx.model.
2026-08-30 19:13:51 +08:00

1769 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))
seenFold := 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")
}
folded := asciiFold(model)
if _, exists := seenFold[folded]; exists {
return fmt.Errorf("plugin meta models must be unique case-insensitively")
}
seenFold[folded] = struct{}{}
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
}