mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-03 23:02:58 +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.
1461 lines
50 KiB
Go
1461 lines
50 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/dto"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
"github.com/QuantumNous/new-api/model"
|
|
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
|
|
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
const contextKeyTaskPluginEndpointModel = "task_plugin_endpoint_model_request"
|
|
|
|
var errTaskPluginUnsupportedMediaType = errors.New("unsupported task plugin media type")
|
|
|
|
const taskPluginInvalidRouteResult = "plugin returned an invalid route result"
|
|
|
|
const (
|
|
maxTaskPluginFormFields = 256
|
|
maxTaskPluginMultipartParts = 256
|
|
maxTaskPluginFiles = 32
|
|
maxTaskPluginFieldNameBytes = 256
|
|
maxTaskPluginFieldValueBytes = 1 << 20
|
|
maxTaskPluginFilenameBytes = 255
|
|
)
|
|
|
|
// PrepareTaskPluginRoute resolves and executes the pinned declarative route.
|
|
// Query requests terminate here so channel distribution and billing are never
|
|
// entered; submit requests continue through the remaining route handlers.
|
|
func PrepareTaskPluginRoute() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedRoute)
|
|
pinned, ok := pinnedValue.(pluginruntime.PinnedRoute)
|
|
if !exists || !ok || pinned.Plugin == nil {
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusInternalServerError, "")
|
|
return
|
|
}
|
|
c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{
|
|
Generation: pinned.Generation,
|
|
Plugin: pinned.Plugin,
|
|
})
|
|
generation := uint64(0)
|
|
if pinned.Generation != nil {
|
|
generation = pinned.Generation.Number
|
|
}
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_start generation=%d plugin=%q method=%q declared_type=%q",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
pinned.Route.Method,
|
|
pinned.Route.Type,
|
|
)
|
|
|
|
requestContext, err := buildTaskPluginRouteRequest(c)
|
|
c.Set(pluginruntime.ContextKeyRouteRequest, requestContext)
|
|
if err != nil {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=invalid_request",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
status := http.StatusBadRequest
|
|
if errors.Is(err, errTaskPluginUnsupportedMediaType) {
|
|
status = http.StatusUnsupportedMediaType
|
|
}
|
|
abortTaskPluginRouteErrorDetail(c, status, err.Error())
|
|
return
|
|
}
|
|
bodyObject, _ := requestContext.Body.(map[string]any)
|
|
bodyKind, _ := bodyObject["kind"].(string)
|
|
if pinned.Route.Type == pluginruntime.RouteTypeQuery && bodyKind != string(pluginruntime.BodyNone) || pinned.Route.Type != pluginruntime.RouteTypeQuery && bodyKind != string(pluginruntime.BodyJSON) {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=body_kind_mismatch body_kind=%q",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
bodyKind,
|
|
)
|
|
detail := "this route requires a JSON body"
|
|
if pinned.Route.Type == pluginruntime.RouteTypeQuery {
|
|
detail = "unsupported request body for this operation"
|
|
}
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusUnsupportedMediaType, detail)
|
|
return
|
|
}
|
|
if pinned.Route.Type == pluginruntime.RouteTypeQuery {
|
|
taskID := requestContext.Params[pinned.Route.TaskIDParam]
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=query renderer=%q task_count=1 distribute=false",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
pinned.Route.Render,
|
|
)
|
|
renderTaskPluginQuery(c, pinned, requestContext, []string{taskID}, pinned.Route.Render, false)
|
|
return
|
|
}
|
|
|
|
if len(pinned.Route.Models) > 0 {
|
|
bodyValue, _ := bodyObject["value"].(map[string]any)
|
|
claimedModel, _ := bodyValue["model"].(string)
|
|
if claimedModel == "" || !slices.Contains(pinned.Route.Models, claimedModel) {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=model_not_allowed model=%q",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
claimedModel,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, fmt.Sprintf("model %q is not allowed on this route", claimedModel))
|
|
return
|
|
}
|
|
}
|
|
|
|
hookStarted := time.Now()
|
|
resolvedValue, err := pinned.Plugin.Engine.CallMember(c.Request.Context(), "native", pinned.Route.Decode, requestContext.JSValue())
|
|
if err != nil {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=hook_failed err=%q elapsed_ms=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
err.Error(),
|
|
time.Since(hookStarted).Milliseconds(),
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginHookDetail(err))
|
|
return
|
|
}
|
|
resolved, ok := resolvedValue.(map[string]any)
|
|
if !ok {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=result_not_object elapsed_ms=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
time.Since(hookStarted).Milliseconds(),
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
kind, ok := resolved["kind"].(string)
|
|
if !ok {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=missing_kind elapsed_ms=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
time.Since(hookStarted).Milliseconds(),
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
if _, forbidden := resolved["renderer"]; forbidden {
|
|
logger.LogWarn(c, "task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=forbidden_renderer", generation, pinned.Plugin.Meta.Key)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
|
|
switch kind {
|
|
case string(pluginruntime.RouteTypeSubmit):
|
|
modelName, valid := resolved["model"].(string)
|
|
if !valid || strings.TrimSpace(modelName) == "" {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=invalid_model",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, "decoded request is missing a model")
|
|
return
|
|
}
|
|
owned := slices.Contains(pinned.Plugin.Meta.Models, modelName)
|
|
if !owned {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=model_not_owned model=%q",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
modelName,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, fmt.Sprintf("model %q is not served by this plugin", modelName))
|
|
return
|
|
}
|
|
if len(pinned.Route.Models) > 0 && !slices.Contains(pinned.Route.Models, modelName) {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=resolved_model_not_allowed model=%q",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
modelName,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, fmt.Sprintf("model %q is not allowed on this route", modelName))
|
|
return
|
|
}
|
|
action := pinned.Route.Action
|
|
if resolvedAction, present := resolved["action"]; present {
|
|
actionValue, actionOK := resolvedAction.(string)
|
|
if !actionOK {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=invalid_action",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
if strings.TrimSpace(actionValue) != "" {
|
|
action = actionValue
|
|
}
|
|
}
|
|
if replacementBody, present := resolved["requestBody"]; present {
|
|
requestContext.RequestBody = replacementBody
|
|
c.Set(pluginruntime.ContextKeyRouteRequest, requestContext)
|
|
}
|
|
c.Set("task_request", requestContext.RequestBody)
|
|
c.Set("resolved_task_model", modelName)
|
|
c.Set("expected_task_plugin_key", pinned.Plugin.Meta.Key)
|
|
c.Set("task_plugin_key", pinned.Plugin.Meta.Key)
|
|
c.Set("platform", pinned.Plugin.Meta.Key)
|
|
service.AppendTaskPluginIdentityFilter(c, pinned.Plugin.Meta.Key)
|
|
if action != "" {
|
|
c.Set("task_action", action)
|
|
}
|
|
c.Set("relay_mode", relayconstant.RelayModeVideoSubmit)
|
|
if intentErr := applyOriginTaskIntent(c, resolved, pinned.Plugin.Meta); intentErr != nil {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=origin_task reason=%s",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
intentErr.Code,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, intentErr.StatusCode, intentErr.Message)
|
|
return
|
|
}
|
|
_, bodyReplaced := resolved["requestBody"]
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=submit model=%q action_present=%t request_body_replaced=%t distribute=true elapsed_ms=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
modelName,
|
|
action != "",
|
|
bodyReplaced,
|
|
time.Since(hookStarted).Milliseconds(),
|
|
)
|
|
c.Next()
|
|
case string(pluginruntime.RouteTypeQuery):
|
|
if pinned.Route.Type != pluginruntime.RouteTypeDynamic {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=query_from_non_dynamic_route",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
taskIDs, valid := resolvedTaskPluginIDs(resolved["taskIds"])
|
|
if !valid {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=invalid_query_result",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=query renderer=%q task_count=%d distribute=false elapsed_ms=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
pinned.Route.Render,
|
|
len(taskIDs),
|
|
time.Since(hookStarted).Milliseconds(),
|
|
)
|
|
renderTaskPluginQuery(c, pinned, requestContext, taskIDs, pinned.Route.Render, true)
|
|
default:
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=unsupported_kind",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
}
|
|
}
|
|
}
|
|
|
|
// PinTaskPluginEndpoint decides shared-endpoint ownership without executing
|
|
// plugin code. Invalid or unidentifiable ordinary requests deliberately fall
|
|
// through so the existing endpoint remains responsible for its validation.
|
|
func PinTaskPluginEndpoint() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
generation := pluginruntime.DefaultRegistry.Generation()
|
|
if generation == nil {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
modelRequest, err := getModelFromRequest(c)
|
|
if err != nil {
|
|
if _, _, protocolPath := pluginruntime.LookupHostProtocolOperation(c.Request.Method, c.Request.URL.Path); protocolPath {
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid task protocol request")
|
|
return
|
|
}
|
|
c.Next()
|
|
return
|
|
}
|
|
claimedModel := modelRequest.Model
|
|
if strings.TrimSpace(claimedModel) == "" {
|
|
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
|
c.Next()
|
|
return
|
|
}
|
|
lookupModel := claimedModel
|
|
pinModel := claimedModel
|
|
mappedModel := ""
|
|
rewriteTo := ""
|
|
if declared, ok := generation.CanonicalModel(claimedModel); ok {
|
|
lookupModel = declared
|
|
pinModel = declared
|
|
if claimedModel != declared {
|
|
rewriteTo = declared
|
|
}
|
|
} else if target, ok := model.ResolveTaskModelAlias(generation, claimedModel); ok {
|
|
if target.Declared == "" {
|
|
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
|
c.Next()
|
|
return
|
|
}
|
|
lookupModel = target.Declared
|
|
pinModel = target.Alias
|
|
mappedModel = target.Declared
|
|
if claimedModel != target.Alias {
|
|
rewriteTo = target.Alias
|
|
}
|
|
}
|
|
binding, found := generation.LookupEndpoint(c.Request.Method, c.Request.URL.Path, lookupModel)
|
|
if !found || binding.Plugin == nil {
|
|
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
|
c.Next()
|
|
return
|
|
}
|
|
if rewriteTo != "" {
|
|
if rewriteErr := rewriteTaskPluginJSONModel(c, rewriteTo); rewriteErr != nil {
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid task protocol request")
|
|
return
|
|
}
|
|
}
|
|
modelRequest.Model = pinModel
|
|
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
|
candidates := generation.LookupEndpointCandidates(c.Request.Method, c.Request.URL.Path, lookupModel)
|
|
if len(candidates) == 0 {
|
|
candidates = []pluginruntime.ProtocolBinding{binding}
|
|
}
|
|
if definition, known := pluginruntime.HostProtocol(binding.Protocol); known && len(definition.DefinedModes()) > 0 {
|
|
stream, background := jsonBodyBoolFlags(c)
|
|
required := make([]string, 0, 2)
|
|
if stream {
|
|
required = append(required, "stream")
|
|
}
|
|
if background {
|
|
required = append(required, "background")
|
|
}
|
|
if !stream && !background {
|
|
required = append(required, "sync")
|
|
}
|
|
unfiltered := candidates
|
|
filtered := make([]pluginruntime.ProtocolBinding, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
if candidate.Plugin == nil {
|
|
continue
|
|
}
|
|
supported := true
|
|
for _, mode := range required {
|
|
if !candidate.Plugin.Meta.ProtocolSupports(candidate.Protocol, mode) {
|
|
supported = false
|
|
break
|
|
}
|
|
}
|
|
if supported {
|
|
filtered = append(filtered, candidate)
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, unsupportedProtocolFormMessage(unfiltered, binding.Protocol, stream, background))
|
|
return
|
|
}
|
|
candidates = filtered
|
|
binding = candidates[0]
|
|
}
|
|
|
|
pin := pluginruntime.PinnedPlugin{Generation: generation, Plugin: binding.Plugin}
|
|
pinnedEndpoint := pluginruntime.PinnedEndpoint{
|
|
Generation: generation,
|
|
Plugin: binding.Plugin,
|
|
Protocol: binding.Protocol,
|
|
Operation: binding.Operation,
|
|
Model: pinModel,
|
|
MappedModel: mappedModel,
|
|
Candidates: candidates,
|
|
}
|
|
c.Set(pluginruntime.ContextKeyPinnedPlugin, pin)
|
|
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pinnedEndpoint)
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=claimed generation=%d plugin=%q version=%q method=%q protocol=%q model=%q",
|
|
generation.Number,
|
|
binding.Plugin.Meta.Key,
|
|
binding.Plugin.Meta.Version,
|
|
binding.Operation.Methods[0],
|
|
binding.Protocol,
|
|
pinModel,
|
|
)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func jsonBodyBoolFlags(c *gin.Context) (stream, background bool) {
|
|
storage, err := common.GetBodyStorage(c)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
requestBody, err := storage.Bytes()
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
values := gjson.GetManyBytes(requestBody, "stream", "background")
|
|
return values[0].Type == gjson.True, values[1].Type == gjson.True
|
|
}
|
|
|
|
func unsupportedProtocolFormMessage(candidates []pluginruntime.ProtocolBinding, protocol string, stream, background bool) string {
|
|
supports := func(mode string) bool {
|
|
for _, candidate := range candidates {
|
|
if candidate.Plugin != nil && candidate.Plugin.Meta.ProtocolSupports(protocol, mode) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if stream && !supports("stream") {
|
|
if supports("background") {
|
|
return `Streaming is not supported for this model. Set "stream": false, or use "background": true and retrieve the response later.`
|
|
}
|
|
return `Streaming is not supported for this model. Set "stream": false.`
|
|
}
|
|
if background && !supports("background") {
|
|
return `Background mode is not supported for this model. Remove "background": true.`
|
|
}
|
|
forms := make([]string, 0, 2)
|
|
if supports("stream") {
|
|
forms = append(forms, `"stream": true`)
|
|
}
|
|
if supports("background") {
|
|
forms = append(forms, `"background": true`)
|
|
}
|
|
message := "Synchronous non-streaming requests are not supported for this model."
|
|
if len(forms) == 0 {
|
|
return message
|
|
}
|
|
return message + " Set " + strings.Join(forms, " or ") + "."
|
|
}
|
|
|
|
// TaskPluginEndpointOnly applies middleware only after a shared endpoint has
|
|
// been claimed. This preserves the original middleware chain for unclaimed
|
|
// video requests while enforcing the plugin route protections on claimed ones.
|
|
func TaskPluginEndpointOnly(handler gin.HandlerFunc) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if _, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); !exists {
|
|
c.Next()
|
|
return
|
|
}
|
|
handler(c)
|
|
}
|
|
}
|
|
|
|
// PrepareTaskPluginEndpoint normalizes a claimed shared request through the
|
|
// deterministic parser pinned before distribution. A shared-model request can
|
|
// later rebind to another declared legacy provider from the same generation.
|
|
func PrepareTaskPluginEndpoint() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint)
|
|
pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint)
|
|
if !exists {
|
|
c.Next()
|
|
return
|
|
}
|
|
if !ok || pinned.Generation == nil || pinned.Plugin == nil {
|
|
abortWithOpenAiMessage(c, http.StatusInternalServerError, "Task protocol request failed")
|
|
return
|
|
}
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_start generation=%d plugin=%q protocol=%q claimed_model=%q",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
pinned.Protocol,
|
|
pinned.Model,
|
|
)
|
|
if !pluginruntime.SupportsHostProtocol(pinned.Protocol) {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=protocol_check reason=unsupported_protocol",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortWithOpenAiMessage(c, http.StatusNotImplemented, "Task protocol bridge is not available")
|
|
return
|
|
}
|
|
requestContext, err := buildTaskPluginRouteRequest(c)
|
|
if err != nil {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=invalid_request",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
status := http.StatusBadRequest
|
|
if errors.Is(err, errTaskPluginUnsupportedMediaType) {
|
|
status = http.StatusUnsupportedMediaType
|
|
}
|
|
abortWithOpenAiMessage(c, status, err.Error())
|
|
return
|
|
}
|
|
if body, ok := requestContext.Body.(map[string]any); ok {
|
|
if fields, ok := body["fields"].(map[string][]string); ok {
|
|
if values := fields["model"]; len(values) > 0 && values[0] != pinned.Model {
|
|
fields["model"][0] = pinned.Model
|
|
}
|
|
}
|
|
}
|
|
bodyObject, _ := requestContext.Body.(map[string]any)
|
|
bodyKind, _ := bodyObject["kind"].(string)
|
|
allowedBody := false
|
|
for _, allowed := range pinned.Operation.BodyKinds {
|
|
if bodyKind == string(allowed) {
|
|
allowedBody = true
|
|
break
|
|
}
|
|
}
|
|
if !allowedBody {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=body_kind_mismatch body_kind=%q",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
bodyKind,
|
|
)
|
|
detail := "unsupported request body for this operation"
|
|
if len(pinned.Operation.BodyKinds) == 1 && pinned.Operation.BodyKinds[0] == pluginruntime.BodyJSON {
|
|
detail = "this route requires a JSON body"
|
|
}
|
|
abortWithOpenAiMessage(c, http.StatusUnsupportedMediaType, detail)
|
|
return
|
|
}
|
|
stream := false
|
|
if body, bodyOK := requestContext.Body.(map[string]any); bodyOK && body["kind"] == string(pluginruntime.BodyJSON) {
|
|
requestBody, _ := body["value"].(map[string]any)
|
|
if streamValue, present := requestBody["stream"]; present {
|
|
stream, ok = streamValue.(bool)
|
|
if !ok {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=invalid_stream_flag",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, "stream must be a boolean")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
protocolContext := pluginruntime.ProtocolRequestContext{
|
|
RouteRequestContext: requestContext,
|
|
Protocol: pinned.Protocol,
|
|
Operation: pinned.Operation.Name,
|
|
Model: pinned.Model,
|
|
UpstreamModel: pinned.MappedModel,
|
|
Stream: stream,
|
|
}
|
|
c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
|
|
hookStarted := time.Now()
|
|
// Parsing belongs to the durable task submission path. A client
|
|
// disconnect only stops the later Responses observation.
|
|
resolvedValue, callErr := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(
|
|
context.WithoutCancel(c.Request.Context()),
|
|
pluginruntime.DefaultCallTimeout,
|
|
"protocols",
|
|
[]string{pinned.Protocol, "decodeRequest"},
|
|
protocolContext.JSValue(),
|
|
)
|
|
if callErr != nil {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=hook_failed err=%q elapsed_ms=%d",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
callErr.Error(),
|
|
time.Since(hookStarted).Milliseconds(),
|
|
)
|
|
detail := taskPluginHookDetail(callErr)
|
|
if detail == "" {
|
|
detail = "Invalid task protocol request"
|
|
}
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, detail)
|
|
return
|
|
}
|
|
resolved, ok := resolvedValue.(map[string]any)
|
|
if !ok {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=result_not_object elapsed_ms=%d",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
time.Since(hookStarted).Milliseconds(),
|
|
)
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
if kind, _ := resolved["kind"].(string); kind != string(pluginruntime.RouteTypeSubmit) {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=unsupported_kind",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
resolvedModel, ok := resolved["model"].(string)
|
|
if !ok || strings.TrimSpace(resolvedModel) == "" {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=invalid_model",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, "decoded request is missing a model")
|
|
return
|
|
}
|
|
modelOwned := slices.Contains(pinned.Plugin.Meta.Models, resolvedModel)
|
|
mappedPin := pinned.MappedModel != ""
|
|
if resolvedModel != pinned.Model || (!modelOwned && !mappedPin) {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=resolved_model_not_owned claimed_model=%q resolved_model=%q",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
pinned.Model,
|
|
resolvedModel,
|
|
)
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, fmt.Sprintf("model %q is not served by this plugin", resolvedModel))
|
|
return
|
|
}
|
|
|
|
action := ""
|
|
if resolvedAction, present := resolved["action"]; present {
|
|
action, ok = resolvedAction.(string)
|
|
if !ok {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=invalid_action",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
)
|
|
abortWithOpenAiMessage(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
|
return
|
|
}
|
|
}
|
|
_, bodyReplaced := resolved["requestBody"]
|
|
if normalizedBody, present := resolved["requestBody"]; present {
|
|
requestContext.RequestBody = normalizedBody
|
|
}
|
|
c.Set(pluginruntime.ContextKeyRouteRequest, requestContext)
|
|
c.Set("task_request", requestContext.RequestBody)
|
|
c.Set("resolved_task_model", resolvedModel)
|
|
c.Set("expected_task_plugin_key", pinned.Plugin.Meta.Key)
|
|
c.Set("task_plugin_key", pinned.Plugin.Meta.Key)
|
|
c.Set("platform", pinned.Plugin.Meta.Key)
|
|
service.AppendTaskPluginIdentityFilter(c, pinned.Plugin.Meta.Key)
|
|
c.Set("relay_mode", relayconstant.RelayModeVideoSubmit)
|
|
if strings.TrimSpace(action) != "" {
|
|
c.Set("task_action", action)
|
|
}
|
|
if intentErr := applyOriginTaskIntent(c, resolved, pinned.Plugin.Meta); intentErr != nil {
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=origin_task reason=%s",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
intentErr.Code,
|
|
)
|
|
abortWithOpenAiMessage(c, intentErr.StatusCode, intentErr.Message, types.ErrorCode(intentErr.Code))
|
|
return
|
|
}
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=endpoint event=prepared generation=%d plugin=%q protocol=%q claimed_model=%q resolved_model=%q action_present=%t stream=%t request_body_replaced=%t elapsed_ms=%d",
|
|
pinned.Generation.Number,
|
|
pinned.Plugin.Meta.Key,
|
|
pinned.Protocol,
|
|
pinned.Model,
|
|
resolvedModel,
|
|
action != "",
|
|
stream,
|
|
bodyReplaced,
|
|
time.Since(hookStarted).Milliseconds(),
|
|
)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func buildTaskPluginRouteRequest(c *gin.Context) (pluginruntime.RouteRequestContext, error) {
|
|
requestContext := pluginruntime.RouteRequestContext{
|
|
Path: c.Request.URL.Path,
|
|
Method: c.Request.Method,
|
|
Params: make(map[string]string, len(c.Params)),
|
|
Query: make(map[string][]string),
|
|
Body: map[string]any{"kind": string(pluginruntime.BodyNone)},
|
|
}
|
|
for _, param := range c.Params {
|
|
requestContext.Params[param.Key] = param.Value
|
|
}
|
|
for key, values := range c.Request.URL.Query() {
|
|
requestContext.Query[key] = append([]string(nil), values...)
|
|
}
|
|
|
|
contentTypes := c.Request.Header.Values("Content-Type")
|
|
contentType := strings.TrimSpace(c.GetHeader("Content-Type"))
|
|
if len(contentTypes) > 1 {
|
|
canonical := ""
|
|
for _, value := range contentTypes {
|
|
mediaType, params, parseErr := mime.ParseMediaType(value)
|
|
if parseErr != nil {
|
|
return requestContext, parseErr
|
|
}
|
|
current := mime.FormatMediaType(strings.ToLower(mediaType), params)
|
|
if canonical != "" && current != canonical {
|
|
return requestContext, fmt.Errorf("conflicting Content-Type headers")
|
|
}
|
|
canonical = current
|
|
}
|
|
contentType = canonical
|
|
}
|
|
if contentType == "" || c.Request.ContentLength == 0 {
|
|
return requestContext, nil
|
|
}
|
|
mediaType, mediaParams, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
return requestContext, err
|
|
}
|
|
switch {
|
|
case mediaType == "application/json" || strings.HasSuffix(mediaType, "+json"):
|
|
storage, storageErr := common.GetBodyStorage(c)
|
|
if storageErr != nil {
|
|
return requestContext, storageErr
|
|
}
|
|
raw, bytesErr := storage.Bytes()
|
|
if bytesErr != nil {
|
|
return requestContext, bytesErr
|
|
}
|
|
if !utf8.Valid(raw) {
|
|
return requestContext, fmt.Errorf("JSON body must be valid UTF-8")
|
|
}
|
|
var value any
|
|
if err = common.Unmarshal(raw, &value); err != nil {
|
|
return requestContext, err
|
|
}
|
|
requestContext.Body = map[string]any{"kind": string(pluginruntime.BodyJSON), "value": value}
|
|
case mediaType == "application/x-www-form-urlencoded":
|
|
storage, storageErr := common.GetBodyStorage(c)
|
|
if storageErr != nil {
|
|
return requestContext, storageErr
|
|
}
|
|
raw, bytesErr := storage.Bytes()
|
|
if bytesErr != nil {
|
|
return requestContext, bytesErr
|
|
}
|
|
if !utf8.Valid(raw) {
|
|
return requestContext, fmt.Errorf("form body must be valid UTF-8")
|
|
}
|
|
values, parseErr := url.ParseQuery(string(raw))
|
|
if parseErr != nil {
|
|
return requestContext, parseErr
|
|
}
|
|
if err = validateTaskPluginFields(values); err != nil {
|
|
return requestContext, err
|
|
}
|
|
fields := make(map[string][]string, len(values))
|
|
for field, values := range values {
|
|
fields[field] = append([]string(nil), values...)
|
|
}
|
|
requestContext.Body = map[string]any{"kind": string(pluginruntime.BodyForm), "fields": fields}
|
|
case mediaType == "multipart/form-data":
|
|
boundary := mediaParams["boundary"]
|
|
if boundary == "" {
|
|
return requestContext, fmt.Errorf("multipart boundary is required")
|
|
}
|
|
storage, storageErr := common.GetBodyStorage(c)
|
|
if storageErr != nil {
|
|
return requestContext, storageErr
|
|
}
|
|
raw, bytesErr := storage.Bytes()
|
|
if bytesErr != nil {
|
|
return requestContext, bytesErr
|
|
}
|
|
reader := multipart.NewReader(bytes.NewReader(raw), boundary)
|
|
partCount := 0
|
|
fileCount := 0
|
|
fieldCount := 0
|
|
fileLimitMB := constant.MaxFileDownloadMB
|
|
if fileLimitMB <= 0 {
|
|
fileLimitMB = 64
|
|
}
|
|
for {
|
|
part, nextErr := reader.NextPart()
|
|
if nextErr == io.EOF {
|
|
break
|
|
}
|
|
if nextErr != nil {
|
|
return requestContext, nextErr
|
|
}
|
|
partCount++
|
|
if partCount > maxTaskPluginMultipartParts {
|
|
part.Close()
|
|
return requestContext, fmt.Errorf("multipart body exceeds %d parts", maxTaskPluginMultipartParts)
|
|
}
|
|
name := part.FormName()
|
|
if !utf8.ValidString(name) || len(name) == 0 || len(name) > maxTaskPluginFieldNameBytes {
|
|
part.Close()
|
|
return requestContext, fmt.Errorf("invalid multipart field name")
|
|
}
|
|
partMediaType, _, partMediaErr := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
|
if partMediaErr != nil && part.Header.Get("Content-Type") != "" {
|
|
part.Close()
|
|
return requestContext, fmt.Errorf("invalid multipart part Content-Type")
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(partMediaType), "multipart/") {
|
|
part.Close()
|
|
return requestContext, fmt.Errorf("nested multipart is not supported")
|
|
}
|
|
filename := part.FileName()
|
|
if filename == "" {
|
|
fieldCount++
|
|
if fieldCount > maxTaskPluginFormFields {
|
|
part.Close()
|
|
return requestContext, fmt.Errorf("request body exceeds %d fields", maxTaskPluginFormFields)
|
|
}
|
|
value, readErr := io.ReadAll(io.LimitReader(part, maxTaskPluginFieldValueBytes+1))
|
|
part.Close()
|
|
if readErr != nil {
|
|
return requestContext, readErr
|
|
}
|
|
if len(value) > maxTaskPluginFieldValueBytes {
|
|
return requestContext, fmt.Errorf("request field %q exceeds %d bytes", name, maxTaskPluginFieldValueBytes)
|
|
}
|
|
if !utf8.Valid(value) {
|
|
return requestContext, fmt.Errorf("request field %q must be valid UTF-8", name)
|
|
}
|
|
continue
|
|
}
|
|
fileCount++
|
|
if fileCount > maxTaskPluginFiles {
|
|
part.Close()
|
|
return requestContext, fmt.Errorf("multipart body exceeds %d files", maxTaskPluginFiles)
|
|
}
|
|
if !utf8.ValidString(filename) || len(filename) > maxTaskPluginFilenameBytes {
|
|
part.Close()
|
|
return requestContext, fmt.Errorf("invalid multipart filename")
|
|
}
|
|
written, copyErr := io.Copy(io.Discard, io.LimitReader(part, (int64(fileLimitMB)<<20)+1))
|
|
part.Close()
|
|
if copyErr != nil {
|
|
return requestContext, copyErr
|
|
}
|
|
if written > int64(fileLimitMB)<<20 {
|
|
return requestContext, fmt.Errorf("multipart file exceeds %d MB", fileLimitMB)
|
|
}
|
|
}
|
|
form, parseErr := common.ParseMultipartFormReusable(c)
|
|
if parseErr != nil {
|
|
return requestContext, parseErr
|
|
}
|
|
defer form.RemoveAll()
|
|
if err = validateTaskPluginFields(form.Value); err != nil {
|
|
return requestContext, err
|
|
}
|
|
partCount = 0
|
|
fileCount = 0
|
|
for _, values := range form.Value {
|
|
partCount += len(values)
|
|
}
|
|
for _, headers := range form.File {
|
|
partCount += len(headers)
|
|
fileCount += len(headers)
|
|
}
|
|
if partCount > maxTaskPluginMultipartParts {
|
|
return requestContext, fmt.Errorf("multipart body exceeds %d parts", maxTaskPluginMultipartParts)
|
|
}
|
|
if fileCount > maxTaskPluginFiles {
|
|
return requestContext, fmt.Errorf("multipart body exceeds %d files", maxTaskPluginFiles)
|
|
}
|
|
textFields := make(map[string][]string, len(form.Value))
|
|
for field, values := range form.Value {
|
|
textFields[field] = append([]string(nil), values...)
|
|
}
|
|
files := make([]map[string]any, 0)
|
|
for field, headers := range form.File {
|
|
if !utf8.ValidString(field) || len(field) > maxTaskPluginFieldNameBytes {
|
|
return requestContext, fmt.Errorf("invalid multipart file field name")
|
|
}
|
|
for _, header := range headers {
|
|
if !utf8.ValidString(header.Filename) || len(header.Filename) > maxTaskPluginFilenameBytes {
|
|
return requestContext, fmt.Errorf("invalid multipart filename")
|
|
}
|
|
partMediaType, _, mediaErr := mime.ParseMediaType(header.Header.Get("Content-Type"))
|
|
if mediaErr != nil && header.Header.Get("Content-Type") != "" {
|
|
return requestContext, fmt.Errorf("invalid multipart part Content-Type")
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(partMediaType), "multipart/") {
|
|
return requestContext, fmt.Errorf("nested multipart is not supported")
|
|
}
|
|
if header.Size < 0 || header.Size > int64(fileLimitMB)<<20 {
|
|
return requestContext, fmt.Errorf("multipart file exceeds %d MB", fileLimitMB)
|
|
}
|
|
ref := "request_file:" + field
|
|
files = append(files, map[string]any{"ref": ref, "field": field, "filename": header.Filename, "mimeType": header.Header.Get("Content-Type"), "size": header.Size})
|
|
}
|
|
}
|
|
requestContext.Files = files
|
|
requestContext.Body = map[string]any{"kind": string(pluginruntime.BodyMultipart), "fields": textFields, "files": files}
|
|
default:
|
|
return requestContext, fmt.Errorf("%w %q", errTaskPluginUnsupportedMediaType, mediaType)
|
|
}
|
|
return requestContext, nil
|
|
}
|
|
|
|
func validateTaskPluginFields(fields url.Values) error {
|
|
fieldCount := 0
|
|
for name, values := range fields {
|
|
if !utf8.ValidString(name) || len(name) == 0 || len(name) > maxTaskPluginFieldNameBytes {
|
|
return fmt.Errorf("invalid request field name")
|
|
}
|
|
for _, value := range values {
|
|
fieldCount++
|
|
if fieldCount > maxTaskPluginFormFields {
|
|
return fmt.Errorf("request body exceeds %d fields", maxTaskPluginFormFields)
|
|
}
|
|
if !utf8.ValidString(value) {
|
|
return fmt.Errorf("request field %q must be valid UTF-8", name)
|
|
}
|
|
if len(value) > maxTaskPluginFieldValueBytes {
|
|
return fmt.Errorf("request field %q exceeds %d bytes", name, maxTaskPluginFieldValueBytes)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
maxOriginTaskIDs = 16
|
|
maxOriginTaskIDLen = 128
|
|
)
|
|
|
|
type originTaskIntentError struct {
|
|
Code string
|
|
Message string
|
|
StatusCode int
|
|
}
|
|
|
|
// taskPluginLegacyPlatforms lists the Task.Platform values a plugin owns: its
|
|
// key (plugin-era tasks) plus every numeric legacy channel type its driver
|
|
// can drive (pre-plugin tasks, e.g. sora tasks submitted on OpenAI-type
|
|
// channels stored Platform "1").
|
|
func taskPluginLegacyPlatforms(meta pluginruntime.Meta) []constant.TaskPlatform {
|
|
platforms := []constant.TaskPlatform{constant.TaskPlatform(meta.Key)}
|
|
// The sunoapi plugin (adapter for the Suno-API proxy project) was renamed
|
|
// from "suno"; historical rows carry that named Platform value, which
|
|
// predates the numeric channel-type convention below.
|
|
if meta.Key == "sunoapi" {
|
|
platforms = append(platforms, constant.TaskPlatformSuno)
|
|
}
|
|
for _, channelType := range meta.ChannelTypes {
|
|
if channelType <= 0 || channelType == constant.ChannelTypeTaskPlugin {
|
|
continue
|
|
}
|
|
platform := constant.TaskPlatform(strconv.Itoa(channelType))
|
|
if slices.Contains(platforms, platform) {
|
|
continue
|
|
}
|
|
platforms = append(platforms, platform)
|
|
}
|
|
return platforms
|
|
}
|
|
|
|
func applyOriginTaskIntent(c *gin.Context, intent map[string]any, meta pluginruntime.Meta) *originTaskIntentError {
|
|
raw, present := intent["originTaskIds"]
|
|
if !present {
|
|
return nil
|
|
}
|
|
var values []any
|
|
switch typed := raw.(type) {
|
|
case []any:
|
|
values = typed
|
|
case []string:
|
|
values = make([]any, len(typed))
|
|
for i, id := range typed {
|
|
values[i] = id
|
|
}
|
|
default:
|
|
return &originTaskIntentError{Code: "invalid_origin_task_ids", Message: "origin task ids are invalid", StatusCode: http.StatusBadRequest}
|
|
}
|
|
if len(values) > maxOriginTaskIDs {
|
|
return &originTaskIntentError{Code: "invalid_origin_task_ids", Message: "origin task ids are invalid", StatusCode: http.StatusBadRequest}
|
|
}
|
|
ids := make([]string, 0, len(values))
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
id, ok := value.(string)
|
|
if !ok {
|
|
return &originTaskIntentError{Code: "invalid_origin_task_ids", Message: "origin task ids are invalid", StatusCode: http.StatusBadRequest}
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || utf8.RuneCountInString(id) > maxOriginTaskIDLen {
|
|
return &originTaskIntentError{Code: "invalid_origin_task_ids", Message: "origin task ids are invalid", StatusCode: http.StatusBadRequest}
|
|
}
|
|
if _, exists := seen[id]; exists {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
ids = append(ids, id)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
userID := common.GetContextKeyInt(c, constant.ContextKeyUserId)
|
|
platforms := taskPluginLegacyPlatforms(meta)
|
|
allowedPlatform := make(map[constant.TaskPlatform]struct{}, len(platforms))
|
|
for _, platform := range platforms {
|
|
allowedPlatform[platform] = struct{}{}
|
|
}
|
|
|
|
tasks := make([]*model.Task, 0, len(ids))
|
|
channelID := 0
|
|
for _, id := range ids {
|
|
task, exist, err := model.GetByTaskId(userID, id)
|
|
if err != nil {
|
|
return &originTaskIntentError{Code: "origin_task_not_found", Message: "origin task not found or not owned by you", StatusCode: http.StatusInternalServerError}
|
|
}
|
|
if !exist || task == nil {
|
|
return &originTaskIntentError{Code: "origin_task_not_found", Message: "origin task not found or not owned by you", StatusCode: http.StatusBadRequest}
|
|
}
|
|
if _, allowed := allowedPlatform[task.Platform]; !allowed {
|
|
return &originTaskIntentError{Code: "origin_task_platform_mismatch", Message: "origin task does not belong to this plugin", StatusCode: http.StatusBadRequest}
|
|
}
|
|
if channelID == 0 {
|
|
channelID = task.ChannelId
|
|
} else if task.ChannelId != channelID {
|
|
return &originTaskIntentError{Code: "origin_task_channel_conflict", Message: "origin tasks must belong to the same channel", StatusCode: http.StatusBadRequest}
|
|
}
|
|
tasks = append(tasks, task)
|
|
}
|
|
|
|
channel, err := model.CacheGetChannel(channelID)
|
|
if err != nil || channel == nil || channel.Status != common.ChannelStatusEnabled {
|
|
return &originTaskIntentError{Code: "origin_task_channel_disabled", Message: "origin task channel is disabled", StatusCode: http.StatusBadRequest}
|
|
}
|
|
service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
|
|
ChannelId: channel.Id,
|
|
Source: dto.PinSourceOriginTask,
|
|
Rank: dto.PinRankOriginTask,
|
|
RetryMode: dto.PinRetrySameChannel,
|
|
})
|
|
common.SetContextKey(c, constant.ContextKeyOriginTasks, tasks)
|
|
return nil
|
|
}
|
|
|
|
func resolvedTaskPluginIDs(value any) ([]string, bool) {
|
|
values, ok := value.([]any)
|
|
if !ok || len(values) > 100 {
|
|
return nil, false
|
|
}
|
|
taskIDs := make([]string, len(values))
|
|
for index, value := range values {
|
|
taskID, stringOK := value.(string)
|
|
if !stringOK || strings.TrimSpace(taskID) == "" {
|
|
return nil, false
|
|
}
|
|
taskIDs[index] = taskID
|
|
}
|
|
return taskIDs, true
|
|
}
|
|
|
|
func renderTaskPluginQuery(
|
|
c *gin.Context,
|
|
pinned pluginruntime.PinnedRoute,
|
|
requestContext pluginruntime.RouteRequestContext,
|
|
taskIDs []string,
|
|
renderer string,
|
|
multiple bool,
|
|
) {
|
|
generation := uint64(0)
|
|
if pinned.Generation != nil {
|
|
generation = pinned.Generation.Number
|
|
}
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=query event=lookup_start generation=%d plugin=%q renderer=%q requested=%d multiple=%t",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
renderer,
|
|
len(taskIDs),
|
|
multiple,
|
|
)
|
|
userID := common.GetContextKeyInt(c, constant.ContextKeyUserId)
|
|
platforms := taskPluginLegacyPlatforms(pinned.Plugin.Meta)
|
|
tasks, err := model.GetByTaskIdsForPlatforms(userID, platforms, taskIDs)
|
|
if err != nil {
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=query event=lookup_failed generation=%d plugin=%q reason=database_error requested=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
len(taskIDs),
|
|
)
|
|
abortTaskPluginRouteError(c, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=query event=lookup_complete generation=%d plugin=%q requested=%d found=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
len(taskIDs),
|
|
len(tasks),
|
|
)
|
|
tasksByID := make(map[string]*model.Task, len(tasks))
|
|
for _, task := range tasks {
|
|
tasksByID[task.TaskID] = task
|
|
}
|
|
views := make([]map[string]any, 0, len(taskIDs))
|
|
for _, taskID := range taskIDs {
|
|
task := tasksByID[taskID]
|
|
if task == nil {
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=query event=lookup_failed generation=%d plugin=%q reason=task_not_found requested=%d found=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
len(taskIDs),
|
|
len(tasks),
|
|
)
|
|
abortTaskPluginRouteError(c, http.StatusNotFound)
|
|
return
|
|
}
|
|
view, viewErr := service.BuildTaskPluginView(task)
|
|
if viewErr != nil {
|
|
abortTaskPluginRouteError(c, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var viewValue map[string]any
|
|
encoded, marshalErr := common.Marshal(view)
|
|
if marshalErr != nil {
|
|
abortTaskPluginRouteError(c, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if unmarshalErr := common.Unmarshal(encoded, &viewValue); unmarshalErr != nil {
|
|
abortTaskPluginRouteError(c, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
views = append(views, viewValue)
|
|
}
|
|
|
|
var rendererInput any = views
|
|
if !multiple {
|
|
rendererInput = views[0]
|
|
}
|
|
renderStarted := time.Now()
|
|
result, err := pinned.Plugin.Engine.CallPath(c.Request.Context(), "native", []string{renderer}, requestContext.JSValue(), rendererInput)
|
|
if err != nil {
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=query event=render_failed generation=%d plugin=%q renderer=%q reason=hook_failed elapsed_ms=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
renderer,
|
|
time.Since(renderStarted).Milliseconds(),
|
|
)
|
|
abortTaskPluginRouteError(c, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=query event=render_complete generation=%d plugin=%q renderer=%q task_count=%d elapsed_ms=%d",
|
|
generation,
|
|
pinned.Plugin.Meta.Key,
|
|
renderer,
|
|
len(views),
|
|
time.Since(renderStarted).Milliseconds(),
|
|
)
|
|
c.Abort()
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// RespondTaskPluginError gives a pinned plugin a sanitized error DTO and writes
|
|
// its native error body. The host-provided status remains authoritative.
|
|
func RespondTaskPluginError(c *gin.Context, taskErr *dto.TaskError) bool {
|
|
if taskErr == nil {
|
|
return false
|
|
}
|
|
pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedRoute)
|
|
pinned, ok := pinnedValue.(pluginruntime.PinnedRoute)
|
|
if !exists || !ok || pinned.Plugin == nil {
|
|
return false
|
|
}
|
|
sanitized := sanitizedTaskPluginError(taskErr.StatusCode, taskErr.Message)
|
|
requestID := c.GetString(common.RequestIdKey)
|
|
hasRenderer, err := pinned.Plugin.Engine.HasCallablePath(c.Request.Context(), "native", "error")
|
|
requestValue, exists := c.Get(pluginruntime.ContextKeyRouteRequest)
|
|
requestContext, ok := requestValue.(pluginruntime.RouteRequestContext)
|
|
if err == nil && hasRenderer && exists && ok {
|
|
body, callErr := pinned.Plugin.Engine.CallMember(c.Request.Context(), "native", "error", requestContext.JSValue(), map[string]any{
|
|
"code": sanitized.Code,
|
|
"message": sanitized.Message,
|
|
"httpStatus": sanitized.HTTPStatus,
|
|
"retryable": sanitized.Retryable,
|
|
"requestId": requestID,
|
|
})
|
|
if callErr == nil {
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=route event=error_rendered plugin=%q renderer=plugin status=%d code=%q",
|
|
pinned.Plugin.Meta.Key,
|
|
sanitized.HTTPStatus,
|
|
sanitized.Code,
|
|
)
|
|
c.JSON(sanitized.HTTPStatus, body)
|
|
return true
|
|
}
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=error_renderer_failed plugin=%q reason=hook_failed status=%d err=%q",
|
|
pinned.Plugin.Meta.Key,
|
|
sanitized.HTTPStatus,
|
|
callErr.Error(),
|
|
)
|
|
}
|
|
logger.LogWarn(
|
|
c,
|
|
"task_plugin subsystem=route event=error_rendered plugin=%q renderer=host_fallback status=%d code=%q",
|
|
pinned.Plugin.Meta.Key,
|
|
sanitized.HTTPStatus,
|
|
sanitized.Code,
|
|
)
|
|
message := sanitized.Message
|
|
if requestID != "" {
|
|
message = common.MessageWithRequestId(sanitized.Message, requestID)
|
|
}
|
|
c.JSON(sanitized.HTTPStatus, &dto.TaskError{
|
|
Code: sanitized.Code,
|
|
Message: message,
|
|
StatusCode: sanitized.HTTPStatus,
|
|
})
|
|
return true
|
|
}
|
|
|
|
func abortTaskPluginRouteError(c *gin.Context, status int) {
|
|
abortTaskPluginRouteErrorDetail(c, status, "")
|
|
}
|
|
|
|
func abortTaskPluginRouteErrorDetail(c *gin.Context, status int, detail string) {
|
|
taskErr := sanitizedTaskPluginError(status, detail)
|
|
c.Abort()
|
|
if RespondTaskPluginError(c, &dto.TaskError{Code: taskErr.Code, Message: detail, StatusCode: taskErr.HTTPStatus}) {
|
|
return
|
|
}
|
|
message := taskErr.Message
|
|
if requestID := c.GetString(common.RequestIdKey); requestID != "" {
|
|
message = common.MessageWithRequestId(taskErr.Message, requestID)
|
|
}
|
|
c.JSON(taskErr.HTTPStatus, &dto.TaskError{
|
|
Code: taskErr.Code,
|
|
Message: message,
|
|
StatusCode: taskErr.HTTPStatus,
|
|
})
|
|
}
|
|
|
|
func sanitizedTaskPluginError(status int, detail string) dto.TaskPluginError {
|
|
var taskErr dto.TaskPluginError
|
|
switch status {
|
|
case http.StatusBadRequest:
|
|
taskErr = dto.TaskPluginError{Code: "invalid_request", Message: "Invalid request", HTTPStatus: status}
|
|
case http.StatusUnauthorized:
|
|
taskErr = dto.TaskPluginError{Code: "authentication_error", Message: "Authentication failed", HTTPStatus: status}
|
|
case http.StatusForbidden:
|
|
taskErr = dto.TaskPluginError{Code: "permission_denied", Message: "Access denied", HTTPStatus: status}
|
|
case http.StatusNotFound:
|
|
taskErr = dto.TaskPluginError{Code: "task_not_found", Message: "Task not found", HTTPStatus: status}
|
|
case http.StatusConflict:
|
|
taskErr = dto.TaskPluginError{Code: "request_conflict", Message: "Request conflict", HTTPStatus: status}
|
|
case http.StatusTooManyRequests:
|
|
taskErr = dto.TaskPluginError{Code: "rate_limit_exceeded", Message: "Too many requests", HTTPStatus: status, Retryable: true}
|
|
default:
|
|
if status < 400 || status > 599 {
|
|
status = http.StatusInternalServerError
|
|
}
|
|
if status < 500 {
|
|
taskErr = dto.TaskPluginError{Code: "invalid_request", Message: "Invalid request", HTTPStatus: status}
|
|
} else {
|
|
taskErr = dto.TaskPluginError{Code: "server_error", Message: "Task request failed", HTTPStatus: status, Retryable: status >= 500}
|
|
}
|
|
}
|
|
if detail != "" && taskErr.HTTPStatus < 500 {
|
|
taskErr.Message = detail
|
|
}
|
|
return taskErr
|
|
}
|
|
|
|
func taskPluginHookDetail(err error) string {
|
|
var hookErr *pluginruntime.HookError
|
|
if errors.As(err, &hookErr) {
|
|
return hookErr.Message
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func logTaskPluginChannelDecision(c *gin.Context, channel *model.Channel, modelName, event, reason string) {
|
|
expectedPlugin := c.GetString("expected_task_plugin_key")
|
|
if expectedPlugin == "" {
|
|
return
|
|
}
|
|
generation := uint64(0)
|
|
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedPlugin); exists {
|
|
if pinned, ok := pinnedValue.(pluginruntime.PinnedPlugin); ok && pinned.Generation != nil {
|
|
generation = pinned.Generation.Number
|
|
}
|
|
}
|
|
if channel == nil {
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=distribution event=%s generation=%d plugin=%q model=%q reason=%q",
|
|
event,
|
|
generation,
|
|
expectedPlugin,
|
|
modelName,
|
|
reason,
|
|
)
|
|
return
|
|
}
|
|
identityMode := "legacy_channel_type"
|
|
if channel.Type == constant.ChannelTypeTaskPlugin {
|
|
identityMode = "type59_setting"
|
|
}
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=distribution event=%s generation=%d plugin=%q model=%q channel_id=%d channel_type=%d identity_mode=%q reason=%q",
|
|
event,
|
|
generation,
|
|
expectedPlugin,
|
|
modelName,
|
|
channel.Id,
|
|
channel.Type,
|
|
identityMode,
|
|
reason,
|
|
)
|
|
}
|
|
|
|
func PrepareTaskPluginSubmit() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
pluginKey := strings.TrimSpace(c.Param("key"))
|
|
generation := pluginruntime.DefaultRegistry.Generation()
|
|
plugin, ok := generation.Get(pluginKey)
|
|
if !ok {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "task plugin not found", "type": "invalid_request_error"}})
|
|
return
|
|
}
|
|
c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{
|
|
Generation: generation,
|
|
Plugin: plugin,
|
|
})
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=route event=legacy_entry_pinned generation=%d plugin=%q version=%q",
|
|
generation.Number,
|
|
plugin.Meta.Key,
|
|
plugin.Meta.Version,
|
|
)
|
|
var requestBody map[string]any
|
|
if err := common.UnmarshalBodyReusable(c, &requestBody); err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": err.Error(), "type": "invalid_request_error"}})
|
|
return
|
|
}
|
|
modelName, _ := requestBody["model"].(string)
|
|
if strings.TrimSpace(modelName) == "" {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "model is required", "type": "invalid_request_error"}})
|
|
return
|
|
}
|
|
exactOwned := slices.Contains(plugin.Meta.Models, modelName)
|
|
exactAlias := false
|
|
if target, resolved := model.ResolveTaskModelAlias(generation, modelName); resolved && target.Alias == modelName && target.PluginKey == plugin.Meta.Key {
|
|
exactAlias = true
|
|
}
|
|
if !exactOwned && !exactAlias {
|
|
folded := ""
|
|
if declared, ok := generation.CanonicalModel(modelName); ok && slices.Contains(plugin.Meta.Models, declared) && declared != modelName {
|
|
folded = declared
|
|
} else if target, resolved := model.ResolveTaskModelAlias(generation, modelName); resolved && target.PluginKey == plugin.Meta.Key && target.Alias != "" && target.Alias != modelName {
|
|
folded = target.Alias
|
|
}
|
|
if folded != "" {
|
|
if rewriteErr := rewriteTaskPluginJSONModel(c, folded); rewriteErr != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": rewriteErr.Error(), "type": "invalid_request_error"}})
|
|
return
|
|
}
|
|
requestBody["model"] = folded
|
|
modelName = folded
|
|
}
|
|
}
|
|
c.Set("task_request", requestBody)
|
|
c.Set("resolved_task_model", modelName)
|
|
c.Set("expected_task_plugin_key", pluginKey)
|
|
service.AppendTaskPluginIdentityFilter(c, pluginKey)
|
|
c.Set("relay_mode", relayconstant.RelayModeVideoSubmit)
|
|
logger.LogDebug(
|
|
c,
|
|
"task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=submit model=%q distribute=true entry=legacy",
|
|
generation.Number,
|
|
plugin.Meta.Key,
|
|
modelName,
|
|
)
|
|
c.Next()
|
|
}
|
|
}
|