Files
new-api/middleware/task_plugin.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

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()
}
}