mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-01 19:41:57 +00:00
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.
969 lines
31 KiB
Go
969 lines
31 KiB
Go
package jsplugin
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/constant"
|
|
)
|
|
|
|
type RouteType string
|
|
|
|
const (
|
|
RouteTypeSubmit RouteType = "submit"
|
|
RouteTypeQuery RouteType = "query"
|
|
RouteTypeDynamic RouteType = "dynamic"
|
|
)
|
|
|
|
type Route struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Type RouteType `json:"type"`
|
|
Action string `json:"action,omitempty"`
|
|
Decode string `json:"decode,omitempty"`
|
|
Render string `json:"render,omitempty"`
|
|
TaskIDParam string `json:"taskIdParam,omitempty"`
|
|
// Models restricts this route to the listed models. The host matches the
|
|
// canonical top-level "model" body field before any JS hook runs; empty
|
|
// means unrestricted. Must be a subset of meta.models.
|
|
Models []string `json:"models,omitempty"`
|
|
}
|
|
|
|
// ProtocolClaim is one entry of meta.protocols. Models narrows the protocol's
|
|
// endpoint bindings to a subset of meta.models; empty binds every model.
|
|
// Supports names the request forms a mode-bearing protocol accepts; decode
|
|
// and normalize rewrite it into host-table order.
|
|
type ProtocolClaim struct {
|
|
Name string `json:"name"`
|
|
Models []string `json:"models,omitempty"`
|
|
Supports []string `json:"supports,omitempty"`
|
|
objectForm bool
|
|
}
|
|
|
|
// ProtocolMode is one client request form a host protocol operation accepts
|
|
// and the plugin hook that implements it.
|
|
type ProtocolMode struct {
|
|
Name string
|
|
Hook string
|
|
}
|
|
|
|
type BodyKind string
|
|
|
|
const (
|
|
BodyNone BodyKind = "none"
|
|
BodyJSON BodyKind = "json"
|
|
BodyForm BodyKind = "form"
|
|
BodyMultipart BodyKind = "multipart"
|
|
)
|
|
|
|
type HostProtocolOperation struct {
|
|
Name string
|
|
Methods []string
|
|
Path string
|
|
BodyKinds []BodyKind
|
|
ModelField string
|
|
RequiredProtocolMembers []string
|
|
Modes []ProtocolMode
|
|
RequiredDriverHooks []string
|
|
}
|
|
|
|
type HostProtocolDefinition struct {
|
|
Name string
|
|
Operations []HostProtocolOperation
|
|
}
|
|
|
|
var hostProtocols = []HostProtocolDefinition{
|
|
{Name: "openai_responses", Operations: []HostProtocolOperation{
|
|
{Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/responses", BodyKinds: []BodyKind{BodyJSON}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest"}, Modes: []ProtocolMode{{Name: "stream", Hook: "renderEvents"}, {Name: "sync", Hook: "renderFinal"}, {Name: "background", Hook: "renderFinal"}}},
|
|
{Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/responses/:response_id", BodyKinds: []BodyKind{BodyNone}},
|
|
}},
|
|
{Name: "openai_video", Operations: []HostProtocolOperation{
|
|
{Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/videos", BodyKinds: []BodyKind{BodyJSON, BodyMultipart}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest"}},
|
|
{Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/videos/:task_id", BodyKinds: []BodyKind{BodyNone}, RequiredProtocolMembers: []string{"render"}},
|
|
{Name: "content", Methods: []string{http.MethodGet, http.MethodHead}, Path: "/v1/videos/:task_id/content", BodyKinds: []BodyKind{BodyNone}, RequiredDriverHooks: []string{"listArtifacts", "buildContentRequest"}},
|
|
}},
|
|
}
|
|
|
|
func HostProtocol(name string) (HostProtocolDefinition, bool) {
|
|
for _, definition := range hostProtocols {
|
|
if definition.Name == name {
|
|
return definition, true
|
|
}
|
|
}
|
|
return HostProtocolDefinition{}, false
|
|
}
|
|
|
|
func HostProtocols() []HostProtocolDefinition {
|
|
definitions := make([]HostProtocolDefinition, len(hostProtocols))
|
|
for index, definition := range hostProtocols {
|
|
definitions[index] = definition
|
|
definitions[index].Operations = append([]HostProtocolOperation(nil), definition.Operations...)
|
|
for operationIndex := range definitions[index].Operations {
|
|
operation := &definitions[index].Operations[operationIndex]
|
|
operation.Methods = append([]string(nil), operation.Methods...)
|
|
operation.BodyKinds = append([]BodyKind(nil), operation.BodyKinds...)
|
|
operation.RequiredProtocolMembers = append([]string(nil), operation.RequiredProtocolMembers...)
|
|
operation.Modes = append([]ProtocolMode(nil), operation.Modes...)
|
|
operation.RequiredDriverHooks = append([]string(nil), operation.RequiredDriverHooks...)
|
|
}
|
|
}
|
|
return definitions
|
|
}
|
|
|
|
// DefinedModes returns each distinct mode on the protocol in host-table order.
|
|
func (d HostProtocolDefinition) DefinedModes() []ProtocolMode {
|
|
seen := make(map[string]struct{})
|
|
modes := make([]ProtocolMode, 0)
|
|
for _, operation := range d.Operations {
|
|
for _, mode := range operation.Modes {
|
|
if _, exists := seen[mode.Name]; exists {
|
|
continue
|
|
}
|
|
seen[mode.Name] = struct{}{}
|
|
modes = append(modes, mode)
|
|
}
|
|
}
|
|
return modes
|
|
}
|
|
|
|
func orderProtocolSupports(protocol string, supports []string) []string {
|
|
if len(supports) == 0 {
|
|
return supports
|
|
}
|
|
definition, ok := HostProtocol(protocol)
|
|
if !ok {
|
|
return supports
|
|
}
|
|
rank := make(map[string]int)
|
|
for index, mode := range definition.DefinedModes() {
|
|
rank[mode.Name] = index
|
|
}
|
|
ordered := append([]string(nil), supports...)
|
|
slices.SortStableFunc(ordered, func(left, right string) int {
|
|
leftRank, leftKnown := rank[left]
|
|
rightRank, rightKnown := rank[right]
|
|
switch {
|
|
case leftKnown && rightKnown:
|
|
if leftRank < rightRank {
|
|
return -1
|
|
}
|
|
if leftRank > rightRank {
|
|
return 1
|
|
}
|
|
return 0
|
|
case leftKnown:
|
|
return -1
|
|
case rightKnown:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
return ordered
|
|
}
|
|
|
|
func LookupHostProtocolOperation(method, path string) (string, HostProtocolOperation, bool) {
|
|
method = strings.ToUpper(strings.TrimSpace(method))
|
|
for _, definition := range hostProtocols {
|
|
for _, operation := range definition.Operations {
|
|
if operation.Path != path || operation.ModelField == "" {
|
|
continue
|
|
}
|
|
if slices.Contains(operation.Methods, method) {
|
|
return definition.Name, operation, true
|
|
}
|
|
}
|
|
}
|
|
return "", HostProtocolOperation{}, false
|
|
}
|
|
|
|
type RouteBinding struct {
|
|
Plugin *LoadedPlugin
|
|
Route Route
|
|
}
|
|
|
|
type ProtocolBinding struct {
|
|
Plugin *LoadedPlugin
|
|
Protocol string
|
|
Operation HostProtocolOperation
|
|
Model string
|
|
}
|
|
|
|
const (
|
|
ContextKeyPinnedPlugin = "task_plugin_pinned_plugin"
|
|
ContextKeyPinnedRoute = "task_plugin_pinned_route"
|
|
ContextKeyPinnedEndpoint = "task_plugin_pinned_endpoint"
|
|
ContextKeyRouteRequest = "task_plugin_route_request"
|
|
ContextKeyProtocolRequest = "task_plugin_protocol_request"
|
|
)
|
|
|
|
type PinnedPlugin struct {
|
|
Generation *RoutingGeneration
|
|
Plugin *LoadedPlugin
|
|
}
|
|
|
|
type PinnedRoute struct {
|
|
Generation *RoutingGeneration
|
|
Plugin *LoadedPlugin
|
|
Route Route
|
|
}
|
|
|
|
// PinnedEndpoint carries the exact generation and endpoint candidates selected
|
|
// before distribution. Plugin initially names the deterministic request parser;
|
|
// distribution may rebind it to another candidate from the same generation
|
|
// when multiple legacy providers expose the same model.
|
|
type PinnedEndpoint struct {
|
|
Generation *RoutingGeneration
|
|
Plugin *LoadedPlugin
|
|
Protocol string
|
|
Operation HostProtocolOperation
|
|
Model string
|
|
MappedModel string
|
|
Candidates []ProtocolBinding
|
|
}
|
|
|
|
// RouteRequestContext is the canonical request view exposed to declarative
|
|
// routing hooks. RequestBody contains decoded JSON or multipart text fields;
|
|
// raw binary and multipart file bytes remain host-owned.
|
|
type RouteRequestContext struct {
|
|
Path string `json:"path"`
|
|
Method string `json:"method"`
|
|
Params map[string]string `json:"params"`
|
|
Query map[string][]string `json:"query"`
|
|
Body any `json:"body"`
|
|
Files []map[string]any `json:"-"`
|
|
RequestBody any `json:"-"`
|
|
}
|
|
|
|
func (r RouteRequestContext) JSValue() map[string]any {
|
|
params := make(map[string]string, len(r.Params))
|
|
for key, value := range r.Params {
|
|
params[key] = value
|
|
}
|
|
query := make(map[string][]string, len(r.Query))
|
|
for key, values := range r.Query {
|
|
query[key] = append([]string(nil), values...)
|
|
}
|
|
return map[string]any{
|
|
"path": r.Path,
|
|
"method": r.Method,
|
|
"params": params,
|
|
"query": query,
|
|
"body": clonePluginRequestValue(r.Body),
|
|
}
|
|
}
|
|
|
|
func clonePluginRequestValue(value any) any {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
cloned := make(map[string]any, len(typed))
|
|
for key, item := range typed {
|
|
cloned[key] = clonePluginRequestValue(item)
|
|
}
|
|
return cloned
|
|
case []any:
|
|
cloned := make([]any, len(typed))
|
|
for index, item := range typed {
|
|
cloned[index] = clonePluginRequestValue(item)
|
|
}
|
|
return cloned
|
|
case []string:
|
|
return append([]string(nil), typed...)
|
|
case map[string][]string:
|
|
cloned := make(map[string][]string, len(typed))
|
|
for key, values := range typed {
|
|
cloned[key] = append([]string(nil), values...)
|
|
}
|
|
return cloned
|
|
case []map[string]any:
|
|
cloned := make([]map[string]any, len(typed))
|
|
for index, item := range typed {
|
|
cloned[index] = clonePluginRequestValue(item).(map[string]any)
|
|
}
|
|
return cloned
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
type ProtocolRequestContext struct {
|
|
RouteRequestContext
|
|
Protocol string `json:"protocol"`
|
|
Operation string `json:"operation"`
|
|
Model string `json:"model"`
|
|
// UpstreamModel is the declared machine identity when Model is a
|
|
// channel-mapping alias; empty otherwise. Decode hooks that key rate
|
|
// tables or request shaping by model must use it over Model.
|
|
UpstreamModel string `json:"upstreamModel,omitempty"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
func (p ProtocolRequestContext) JSValue() map[string]any {
|
|
value := p.RouteRequestContext.JSValue()
|
|
value["protocol"] = p.Protocol
|
|
value["operation"] = p.Operation
|
|
value["model"] = p.Model
|
|
if p.UpstreamModel != "" {
|
|
value["upstreamModel"] = p.UpstreamModel
|
|
}
|
|
value["stream"] = p.Stream
|
|
return value
|
|
}
|
|
|
|
// SupportsHostProtocol reports whether the current host release has a
|
|
// concrete wire/state machine for an otherwise valid manifest endpoint.
|
|
func SupportsHostProtocol(protocol string) bool { _, ok := HostProtocol(protocol); return ok }
|
|
|
|
// RoutingGeneration is an immutable, request-pinnable view of all effective
|
|
// plugins and their deterministic routing indexes.
|
|
type RoutingGeneration struct {
|
|
Number uint64
|
|
PublishedAt time.Time
|
|
|
|
byKey map[string]*LoadedPlugin
|
|
byModel map[string]*LoadedPlugin
|
|
canonicalModelByFold map[string]string
|
|
byChannelType map[int]*LoadedPlugin
|
|
routeIndex map[string]RouteBinding
|
|
protocolIndex map[string][]ProtocolBinding
|
|
plugins []*LoadedPlugin
|
|
routes []RouteBinding
|
|
runtime http.Handler
|
|
retainCurrent map[string]struct{}
|
|
}
|
|
|
|
var (
|
|
routeMethodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE)$`)
|
|
pathNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
staticSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
|
|
memberNamePattern = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`)
|
|
)
|
|
|
|
var reservedRouteNamespaces = []string{
|
|
"/api",
|
|
"/assets",
|
|
"/setup",
|
|
"/v1/tasks",
|
|
"/console",
|
|
"/login",
|
|
"/forbidden",
|
|
"/sign-in",
|
|
"/sign-up",
|
|
"/forgot-password",
|
|
"/oauth",
|
|
"/otp",
|
|
"/register",
|
|
"/reset",
|
|
"/privacy-policy",
|
|
"/user-agreement",
|
|
"/about",
|
|
"/pricing",
|
|
"/rankings",
|
|
"/user",
|
|
"/401",
|
|
"/403",
|
|
"/404",
|
|
"/500",
|
|
"/503",
|
|
"/chat2link",
|
|
"/system-settings",
|
|
"/channels",
|
|
"/chat",
|
|
"/dashboard",
|
|
"/errors",
|
|
"/keys",
|
|
"/models",
|
|
"/playground",
|
|
"/profile",
|
|
"/redemption-codes",
|
|
"/subscriptions",
|
|
"/system-info",
|
|
"/task-plugins",
|
|
"/usage-logs",
|
|
"/users",
|
|
"/wallet",
|
|
}
|
|
|
|
func (g *RoutingGeneration) Get(key string) (*LoadedPlugin, bool) {
|
|
if g == nil {
|
|
return nil, false
|
|
}
|
|
plugin, ok := g.byKey[key]
|
|
return plugin, ok
|
|
}
|
|
|
|
func (g *RoutingGeneration) GetByChannelType(channelType int) (*LoadedPlugin, bool) {
|
|
if g == nil || channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
|
|
return nil, false
|
|
}
|
|
plugin, ok := g.byChannelType[channelType]
|
|
return plugin, ok
|
|
}
|
|
|
|
// GetByModel returns the deterministic effective plugin metadata used for a
|
|
// model-level host concern such as billing. When multiple providers expose the
|
|
// same model name, the first plugin in generation order owns that shared
|
|
// metadata view.
|
|
func (g *RoutingGeneration) GetByModel(model string) (*LoadedPlugin, bool) {
|
|
if g == nil || model == "" {
|
|
return nil, false
|
|
}
|
|
plugin, ok := g.byModel[model]
|
|
return plugin, ok
|
|
}
|
|
|
|
// CanonicalModel returns the declared spelling for model. An exact byModel
|
|
// hit wins and returns the input unchanged; otherwise the ASCII-folded
|
|
// index is consulted. Miss and nil-receiver return ("", false).
|
|
func (g *RoutingGeneration) CanonicalModel(model string) (string, bool) {
|
|
if g == nil || model == "" {
|
|
return "", false
|
|
}
|
|
if _, ok := g.byModel[model]; ok {
|
|
return model, true
|
|
}
|
|
declared, ok := g.canonicalModelByFold[asciiFold(model)]
|
|
return declared, ok
|
|
}
|
|
|
|
// LookupDeclaredRoute resolves a manifest path declaration. It does not match
|
|
// an incoming concrete URL; runtime matching is delegated to Gin.
|
|
func (g *RoutingGeneration) LookupDeclaredRoute(method, path string) (RouteBinding, bool) {
|
|
if g == nil {
|
|
return RouteBinding{}, false
|
|
}
|
|
normalizedMethod, err := normalizeRouteMethod(method)
|
|
if err != nil {
|
|
return RouteBinding{}, false
|
|
}
|
|
shape, err := routePathShape(path)
|
|
if err != nil {
|
|
return RouteBinding{}, false
|
|
}
|
|
binding, ok := g.routeIndex[normalizedMethod+" "+shape]
|
|
return binding, ok
|
|
}
|
|
|
|
func (g *RoutingGeneration) LookupEndpoint(method, path, model string) (ProtocolBinding, bool) {
|
|
if g == nil {
|
|
return ProtocolBinding{}, false
|
|
}
|
|
normalizedMethod, err := normalizeRouteMethod(method)
|
|
if err != nil {
|
|
return ProtocolBinding{}, false
|
|
}
|
|
bindings := g.protocolIndex[endpointIndexKey(normalizedMethod, path, model)]
|
|
if len(bindings) == 0 {
|
|
return ProtocolBinding{}, false
|
|
}
|
|
return bindings[0], true
|
|
}
|
|
|
|
// LookupEndpointCandidates returns every legacy provider implementation that
|
|
// can serve one shared model endpoint. Candidate order is deterministic and
|
|
// the first binding is the parser used before channel distribution.
|
|
func (g *RoutingGeneration) LookupEndpointCandidates(method, path, model string) []ProtocolBinding {
|
|
if g == nil {
|
|
return nil
|
|
}
|
|
normalizedMethod, err := normalizeRouteMethod(method)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
bindings := g.protocolIndex[endpointIndexKey(normalizedMethod, path, model)]
|
|
return append([]ProtocolBinding(nil), bindings...)
|
|
}
|
|
|
|
func (g *RoutingGeneration) Plugins() []*LoadedPlugin {
|
|
if g == nil {
|
|
return nil
|
|
}
|
|
return append([]*LoadedPlugin(nil), g.plugins...)
|
|
}
|
|
|
|
func (g *RoutingGeneration) Routes() []RouteBinding {
|
|
if g == nil {
|
|
return nil
|
|
}
|
|
return append([]RouteBinding(nil), g.routes...)
|
|
}
|
|
|
|
// RuntimeHandler is the inner router built for this exact generation. It is
|
|
// published in the same atomic pointer as the routing indexes.
|
|
func (g *RoutingGeneration) RuntimeHandler() http.Handler {
|
|
if g == nil {
|
|
return nil
|
|
}
|
|
return g.runtime
|
|
}
|
|
|
|
func (g *RoutingGeneration) RetainsIncumbent(key string) bool {
|
|
if g == nil {
|
|
return false
|
|
}
|
|
_, retained := g.retainCurrent[key]
|
|
return retained
|
|
}
|
|
|
|
// RebuildWithPlugins creates a generation from the supplied exact plugin
|
|
// pointers. It is used when a rejected hot update must retain the incumbent
|
|
// runtime object for that key.
|
|
func (g *RoutingGeneration) RebuildWithPlugins(plugins []*LoadedPlugin) (*RoutingGeneration, error) {
|
|
if g == nil {
|
|
return nil, fmt.Errorf("cannot rebuild a nil routing generation")
|
|
}
|
|
byKey := make(map[string]*LoadedPlugin, len(plugins))
|
|
for _, plugin := range plugins {
|
|
if plugin == nil {
|
|
return nil, fmt.Errorf("cannot rebuild routing generation with a nil plugin")
|
|
}
|
|
if _, exists := g.byKey[plugin.Meta.Key]; !exists {
|
|
return nil, fmt.Errorf("plugin %q is not present in routing generation %d", plugin.Meta.Key, g.Number)
|
|
}
|
|
if _, duplicate := byKey[plugin.Meta.Key]; duplicate {
|
|
return nil, fmt.Errorf("plugin %q appears more than once in routing generation rebuild", plugin.Meta.Key)
|
|
}
|
|
byKey[plugin.Meta.Key] = plugin
|
|
}
|
|
rebuilt, err := buildRoutingGeneration(byKey, nil, false, g.Number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rebuilt.PublishedAt = g.PublishedAt
|
|
rebuilt.retainCurrent = cloneStringSet(g.retainCurrent)
|
|
return rebuilt, nil
|
|
}
|
|
|
|
// WithRuntime returns a shallow immutable copy carrying the prepared inner
|
|
// handler. Callers use it before publication; published generations must not
|
|
// be mutated.
|
|
func (g *RoutingGeneration) WithRuntime(handler http.Handler) *RoutingGeneration {
|
|
if g == nil {
|
|
return nil
|
|
}
|
|
prepared := *g
|
|
prepared.runtime = handler
|
|
return &prepared
|
|
}
|
|
|
|
func cloneStringSet(source map[string]struct{}) map[string]struct{} {
|
|
if len(source) == 0 {
|
|
return nil
|
|
}
|
|
clone := make(map[string]struct{}, len(source))
|
|
for key := range source {
|
|
clone[key] = struct{}{}
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func normalizeRouteMethod(method string) (string, error) {
|
|
method = strings.ToUpper(strings.TrimSpace(method))
|
|
if !routeMethodPattern.MatchString(method) {
|
|
return "", fmt.Errorf("plugin route method %q is not supported", method)
|
|
}
|
|
return method, nil
|
|
}
|
|
|
|
// NormalizeRoutePath validates the canonical path syntax used by plugin route
|
|
// declarations. A trailing slash is allowed and remains significant.
|
|
func NormalizeRoutePath(routePath string) (string, error) {
|
|
if routePath == "" || routePath[0] != '/' {
|
|
return "", fmt.Errorf("plugin route path must start with /")
|
|
}
|
|
if routePath == "/" {
|
|
return "", fmt.Errorf("plugin route path / is reserved")
|
|
}
|
|
if strings.ContainsAny(routePath, "?#%") {
|
|
return "", fmt.Errorf("plugin route path %q must not contain a query, fragment, or percent-encoding", routePath)
|
|
}
|
|
if strings.Contains(routePath, "//") {
|
|
return "", fmt.Errorf("plugin route path %q must not contain empty segments", routePath)
|
|
}
|
|
|
|
segments := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
|
|
seenNames := make(map[string]struct{})
|
|
for index, segment := range segments {
|
|
if segment == "" && index == len(segments)-1 {
|
|
continue
|
|
}
|
|
if segment == "." || segment == ".." {
|
|
return "", fmt.Errorf("plugin route path %q must not contain dot segments", routePath)
|
|
}
|
|
if after, ok := strings.CutPrefix(segment, ":"); ok {
|
|
name := after
|
|
if !pathNamePattern.MatchString(name) {
|
|
return "", fmt.Errorf("plugin route path %q has invalid parameter %q", routePath, segment)
|
|
}
|
|
if _, exists := seenNames[name]; exists {
|
|
return "", fmt.Errorf("plugin route path %q repeats parameter %q", routePath, name)
|
|
}
|
|
seenNames[name] = struct{}{}
|
|
continue
|
|
}
|
|
if after, ok := strings.CutPrefix(segment, "*"); ok {
|
|
name := after
|
|
if index != len(segments)-1 || !pathNamePattern.MatchString(name) {
|
|
return "", fmt.Errorf("plugin route path %q has an invalid catch-all segment", routePath)
|
|
}
|
|
if _, exists := seenNames[name]; exists {
|
|
return "", fmt.Errorf("plugin route path %q repeats parameter %q", routePath, name)
|
|
}
|
|
seenNames[name] = struct{}{}
|
|
continue
|
|
}
|
|
if !staticSegment.MatchString(segment) {
|
|
return "", fmt.Errorf("plugin route path %q has invalid segment %q", routePath, segment)
|
|
}
|
|
}
|
|
return routePath, nil
|
|
}
|
|
|
|
func routePathShape(routePath string) (string, error) {
|
|
normalized, err := NormalizeRoutePath(routePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
segments := strings.Split(strings.TrimPrefix(normalized, "/"), "/")
|
|
for index, segment := range segments {
|
|
if strings.HasPrefix(segment, ":") {
|
|
segments[index] = ":"
|
|
} else if strings.HasPrefix(segment, "*") {
|
|
segments[index] = "*"
|
|
}
|
|
}
|
|
return "/" + strings.Join(segments, "/"), nil
|
|
}
|
|
|
|
func endpointIndexKey(method, path, model string) string {
|
|
return method + "\x00" + path + "\x00" + model
|
|
}
|
|
|
|
func intersectingReservedNamespace(routePath string) (string, bool) {
|
|
for _, namespace := range reservedRouteNamespaces {
|
|
if routePatternIntersectsNamespace(routePath, namespace) {
|
|
return namespace, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func routePatternIntersectsNamespace(routePath, namespace string) bool {
|
|
routeSegments := strings.Split(strings.Trim(strings.TrimPrefix(routePath, "/"), "/"), "/")
|
|
namespaceSegments := strings.Split(strings.TrimPrefix(namespace, "/"), "/")
|
|
for index, namespaceSegment := range namespaceSegments {
|
|
if index >= len(routeSegments) {
|
|
return false
|
|
}
|
|
routeSegment := routeSegments[index]
|
|
if strings.HasPrefix(routeSegment, "*") {
|
|
return true
|
|
}
|
|
if !strings.HasPrefix(routeSegment, ":") && routeSegment != namespaceSegment {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateRoute(route *Route) error {
|
|
if route.Method != strings.ToUpper(strings.TrimSpace(route.Method)) {
|
|
return fmt.Errorf("plugin route method %q must use canonical uppercase spelling", route.Method)
|
|
}
|
|
method, err := normalizeRouteMethod(route.Method)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
route.Method = method
|
|
route.Path, err = NormalizeRoutePath(route.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if namespace, reserved := intersectingReservedNamespace(route.Path); reserved {
|
|
return fmt.Errorf("plugin route path %q intersects reserved namespace %s", route.Path, namespace)
|
|
}
|
|
switch route.Type {
|
|
case RouteTypeSubmit:
|
|
if route.Decode == "" || route.Render == "" || route.TaskIDParam != "" {
|
|
return fmt.Errorf("submit route %s %s must declare decode and render and must not declare taskIdParam", route.Method, route.Path)
|
|
}
|
|
case RouteTypeQuery:
|
|
if route.Decode != "" || strings.TrimSpace(route.Render) == "" {
|
|
return fmt.Errorf("query route %s %s must declare render and must not declare decode", route.Method, route.Path)
|
|
}
|
|
if route.Action != "" {
|
|
return fmt.Errorf("query route %s %s must not declare action", route.Method, route.Path)
|
|
}
|
|
if route.TaskIDParam == "" {
|
|
route.TaskIDParam = "task_id"
|
|
}
|
|
if !pathNamePattern.MatchString(route.TaskIDParam) {
|
|
return fmt.Errorf("query route %s %s has invalid taskIdParam %q", route.Method, route.Path, route.TaskIDParam)
|
|
}
|
|
if !pathHasParameter(route.Path, route.TaskIDParam) {
|
|
return fmt.Errorf("query route %s %s must contain :%s", route.Method, route.Path, route.TaskIDParam)
|
|
}
|
|
case RouteTypeDynamic:
|
|
if route.Decode == "" || route.Render == "" || route.TaskIDParam != "" {
|
|
return fmt.Errorf("dynamic route %s %s must declare decode and render and must not declare taskIdParam", route.Method, route.Path)
|
|
}
|
|
default:
|
|
return fmt.Errorf("plugin route %s %s has unsupported type %q", route.Method, route.Path, route.Type)
|
|
}
|
|
if route.Decode != "" && !memberNamePattern.MatchString(route.Decode) {
|
|
return fmt.Errorf("plugin route %s %s has invalid decode %q", route.Method, route.Path, route.Decode)
|
|
}
|
|
if route.Render != "" && !memberNamePattern.MatchString(route.Render) {
|
|
return fmt.Errorf("plugin route %s %s has invalid render %q", route.Method, route.Path, route.Render)
|
|
}
|
|
if strings.TrimSpace(route.Action) != route.Action {
|
|
return fmt.Errorf("plugin route %s %s action must not have surrounding whitespace", route.Method, route.Path)
|
|
}
|
|
if len(route.Models) > 0 {
|
|
if route.Type == RouteTypeQuery {
|
|
return fmt.Errorf("query route %s %s must not declare models", route.Method, route.Path)
|
|
}
|
|
if err := validateModelScope(route.Models, fmt.Sprintf("route %s %s", route.Method, route.Path)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateModelScope enforces the shared rules for route.models and
|
|
// per-protocol models entries: non-empty canonical names, no duplicates.
|
|
func validateModelScope(models []string, subject string) error {
|
|
seen := make(map[string]struct{}, len(models))
|
|
for _, model := range models {
|
|
if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model {
|
|
return fmt.Errorf("plugin %s models must contain non-empty canonical names", subject)
|
|
}
|
|
folded := asciiFold(model)
|
|
if _, duplicate := seen[folded]; duplicate {
|
|
return fmt.Errorf("plugin %s models must be unique case-insensitively", subject)
|
|
}
|
|
seen[folded] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pathHasParameter(routePath, name string) bool {
|
|
return slices.Contains(strings.Split(routePath, "/"), ":"+name)
|
|
}
|
|
|
|
func ResolveRouteAction(route Route, resolvedAction string) string {
|
|
if strings.TrimSpace(resolvedAction) != "" {
|
|
return resolvedAction
|
|
}
|
|
return route.Action
|
|
}
|
|
|
|
func buildRoutingGeneration(factory, override map[string]*LoadedPlugin, overrideEnabled bool, number uint64) (*RoutingGeneration, error) {
|
|
effective := effectivePlugins(factory, override, overrideEnabled)
|
|
return buildRoutingGenerationFromPlugins(effective, number)
|
|
}
|
|
|
|
func buildRoutingGenerationAdmitting(
|
|
factory, override map[string]*LoadedPlugin,
|
|
overrideEnabled bool,
|
|
number uint64,
|
|
current *RoutingGeneration,
|
|
retainCurrent map[string]struct{},
|
|
) (*RoutingGeneration, map[string]string, error) {
|
|
candidates := effectivePlugins(factory, override, overrideEnabled)
|
|
accepted := make(map[string]*LoadedPlugin, len(candidates))
|
|
currentByKey := make(map[string]*LoadedPlugin)
|
|
if current != nil {
|
|
for _, plugin := range current.plugins {
|
|
currentByKey[plugin.Meta.Key] = plugin
|
|
}
|
|
}
|
|
generation, err := buildRoutingGenerationFromPlugins(accepted, number)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("initialize routing generation: %w", err)
|
|
}
|
|
|
|
unchangedKeys := make([]string, 0)
|
|
changedKeys := make([]string, 0)
|
|
newKeys := make([]string, 0)
|
|
for key, candidate := range candidates {
|
|
incumbent, exists := currentByKey[key]
|
|
switch {
|
|
case exists && candidate == incumbent:
|
|
unchangedKeys = append(unchangedKeys, key)
|
|
case exists:
|
|
changedKeys = append(changedKeys, key)
|
|
default:
|
|
newKeys = append(newKeys, key)
|
|
}
|
|
}
|
|
sort.Strings(unchangedKeys)
|
|
sort.Strings(changedKeys)
|
|
sort.Strings(newKeys)
|
|
|
|
routingErrors := make(map[string]string)
|
|
orderedKeys := append(unchangedKeys, changedKeys...)
|
|
orderedKeys = append(orderedKeys, newKeys...)
|
|
rejectedKeys := make([]string, 0)
|
|
for _, key := range orderedKeys {
|
|
candidate := candidates[key]
|
|
accepted[key] = candidate
|
|
trial, trialErr := buildRoutingGenerationFromPlugins(accepted, number)
|
|
if trialErr == nil {
|
|
generation = trial
|
|
continue
|
|
}
|
|
delete(accepted, key)
|
|
routingErrors[key] = fmt.Sprintf("plugin %s rejected from routing generation: %v", key, trialErr)
|
|
rejectedKeys = append(rejectedKeys, key)
|
|
}
|
|
|
|
for _, key := range rejectedKeys {
|
|
candidate := candidates[key]
|
|
incumbent, hasIncumbent := currentByKey[key]
|
|
_, mayRetain := retainCurrent[key]
|
|
if !hasIncumbent || !mayRetain || incumbent == candidate {
|
|
continue
|
|
}
|
|
accepted[key] = incumbent
|
|
fallback, fallbackErr := buildRoutingGenerationFromPlugins(accepted, number)
|
|
if fallbackErr == nil {
|
|
generation = fallback
|
|
continue
|
|
}
|
|
delete(accepted, key)
|
|
}
|
|
generation.retainCurrent = cloneStringSet(retainCurrent)
|
|
return generation, routingErrors, nil
|
|
}
|
|
|
|
func effectivePlugins(factory, override map[string]*LoadedPlugin, overrideEnabled bool) map[string]*LoadedPlugin {
|
|
effective := make(map[string]*LoadedPlugin, len(factory)+len(override))
|
|
maps.Copy(effective, factory)
|
|
if overrideEnabled {
|
|
maps.Copy(effective, override)
|
|
}
|
|
return effective
|
|
}
|
|
|
|
func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, number uint64) (*RoutingGeneration, error) {
|
|
keys := make([]string, 0, len(effective))
|
|
for key := range effective {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
generation := &RoutingGeneration{
|
|
Number: number,
|
|
PublishedAt: time.Now(),
|
|
byKey: make(map[string]*LoadedPlugin, len(effective)),
|
|
byModel: make(map[string]*LoadedPlugin),
|
|
canonicalModelByFold: make(map[string]string),
|
|
byChannelType: make(map[int]*LoadedPlugin),
|
|
routeIndex: make(map[string]RouteBinding),
|
|
protocolIndex: make(map[string][]ProtocolBinding),
|
|
plugins: make([]*LoadedPlugin, 0, len(effective)),
|
|
}
|
|
for _, key := range keys {
|
|
plugin := effective[key]
|
|
generation.byKey[key] = plugin
|
|
generation.plugins = append(generation.plugins, plugin)
|
|
for _, model := range plugin.Meta.Models {
|
|
if _, exists := generation.byModel[model]; !exists {
|
|
generation.byModel[model] = plugin
|
|
}
|
|
folded := asciiFold(model)
|
|
if existing, exists := generation.canonicalModelByFold[folded]; exists {
|
|
if existing != model {
|
|
otherKey := plugin.Meta.Key
|
|
if other, ok := generation.byModel[existing]; ok {
|
|
otherKey = other.Meta.Key
|
|
}
|
|
return nil, fmt.Errorf("plugin %s model %q conflicts with plugin %s model %q", plugin.Meta.Key, model, otherKey, existing)
|
|
}
|
|
continue
|
|
}
|
|
generation.canonicalModelByFold[folded] = model
|
|
}
|
|
|
|
for _, channelType := range plugin.Meta.ChannelTypes {
|
|
if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
|
|
continue
|
|
}
|
|
if other, exists := generation.byChannelType[channelType]; exists {
|
|
return nil, fmt.Errorf("plugin %s channelType %d conflicts with plugin %s", plugin.Meta.Key, channelType, other.Meta.Key)
|
|
}
|
|
generation.byChannelType[channelType] = plugin
|
|
}
|
|
|
|
for _, route := range plugin.Meta.Routes {
|
|
shape, err := routePathShape(route.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
indexKey := route.Method + " " + shape
|
|
if other, exists := generation.routeIndex[indexKey]; exists {
|
|
return nil, fmt.Errorf("plugin %s route %s %s conflicts with plugin %s route %s", plugin.Meta.Key, route.Method, route.Path, other.Plugin.Meta.Key, other.Route.Path)
|
|
}
|
|
binding := RouteBinding{Plugin: plugin, Route: route}
|
|
generation.routeIndex[indexKey] = binding
|
|
generation.routes = append(generation.routes, binding)
|
|
}
|
|
|
|
for _, claim := range plugin.Meta.Protocols {
|
|
definition, _ := HostProtocol(claim.Name)
|
|
boundModels := plugin.Meta.Models
|
|
if len(claim.Models) > 0 {
|
|
boundModels = claim.Models
|
|
}
|
|
for _, operation := range definition.Operations {
|
|
if operation.ModelField == "" {
|
|
continue
|
|
}
|
|
for _, method := range operation.Methods {
|
|
for _, model := range boundModels {
|
|
indexKey := endpointIndexKey(method, operation.Path, model)
|
|
bindings := generation.protocolIndex[indexKey]
|
|
if len(bindings) > 0 {
|
|
other := bindings[0]
|
|
legacyProviders := len(plugin.Meta.ChannelTypes) > 0 && len(other.Plugin.Meta.ChannelTypes) > 0
|
|
if !legacyProviders || claim.Name != other.Protocol {
|
|
return nil, fmt.Errorf("plugin %s protocol %s %s model %q conflicts with plugin %s", plugin.Meta.Key, method, operation.Path, model, other.Plugin.Meta.Key)
|
|
}
|
|
}
|
|
generation.protocolIndex[indexKey] = append(bindings, ProtocolBinding{Plugin: plugin, Protocol: claim.Name, Operation: operation, Model: model})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return generation, nil
|
|
}
|
|
|
|
// PreflightRoutingConflict reports whether admitting candidate into the
|
|
// current generation would collide on a channel type, native route, or
|
|
// protocol-model binding. A same-key entry is replaced first so re-uploading
|
|
// a plugin (or overriding a factory built-in) does not self-conflict.
|
|
func PreflightRoutingConflict(current *RoutingGeneration, candidate *LoadedPlugin) error {
|
|
if candidate == nil {
|
|
return fmt.Errorf("cannot preflight a nil plugin")
|
|
}
|
|
effective := make(map[string]*LoadedPlugin)
|
|
number := uint64(0)
|
|
if current != nil {
|
|
for _, plugin := range current.Plugins() {
|
|
effective[plugin.Meta.Key] = plugin
|
|
}
|
|
number = current.Number
|
|
}
|
|
effective[candidate.Meta.Key] = candidate
|
|
_, err := buildRoutingGenerationFromPlugins(effective, number)
|
|
return err
|
|
}
|