Ctrl K
ComunidadGuías…

middleware/task_plugin.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package middleware

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/MoeForge/moego/ojson"
	"github.com/QuantumNous/new-api/common"
	"github.com/QuantumNous/new-api/constant"
	"github.com/QuantumNous/new-api/dto"
	"github.com/QuantumNous/new-api/i18n"
	"github.com/QuantumNous/new-api/logger"
	"github.com/QuantumNous/new-api/model"
	pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
	taskjsplugin "github.com/QuantumNous/new-api/relay/channel/task/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"
)

const contextKeyTaskPluginEndpointModel = "task_plugin_endpoint_model_request"

const contextKeyTaskPluginEndpointSelection = "task_plugin_endpoint_channel_selection"

type taskPluginEndpointSelection struct {
	Generation *pluginruntime.RoutingGeneration
	Channel    *model.Channel
	Model      string
}

var errTaskPluginUnsupportedMediaType = errors.New("unsupported task plugin media type")

const taskPluginInvalidRouteResult = "plugin returned an invalid route result"

// Text fields have no limit of their own: like a JSON body, they are bounded
// by the request body limit, so a JSON request sent as a multipart field may
// be as large as the same request sent as the body.
const (
	maxTaskPluginFormFields     = 256
	maxTaskPluginMultipartParts = 256
	maxTaskPluginFiles          = 32
	maxTaskPluginFieldNameBytes = 256
	maxTaskPluginFilenameBytes  = 255
)

// NativeRouteCredential accepts, on the native routes of the built-in plugins
// listed here, the vendor's official API-key form as the gateway token and
// rewrites it into the Bearer form TokenAuth reads: Vidu's
// "Authorization: Token <key>", and the Gemini API's "x-goog-api-key: <key>"
// or "?key=<key>". A header the client already sent as Authorization wins.
// "?key=" is taken out of the URL in every case, so decoders, the stored
// request path and later handlers never see it. Every other plugin's routes
// accept the Bearer form only. pluginKeys are the plugins serving the route.
func NativeRouteCredential(pluginKeys ...string) gin.HandlerFunc {
	viduToken := slices.Contains(pluginKeys, "vidu")
	googleKey := slices.Contains(pluginKeys, "google")
	return func(c *gin.Context) {
		header := c.Request.Header
		if viduToken {
			if scheme, token, found := strings.Cut(header.Get("Authorization"), " "); found && strings.EqualFold(scheme, "Token") {
				header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
			}
		}
		if googleKey {
			queryKey, removed := "", false
			kept := make([]string, 0)
			for part := range strings.SplitSeq(c.Request.URL.RawQuery, "&") {
				rawName, rawValue, _ := strings.Cut(part, "=")
				if name, err := url.QueryUnescape(rawName); err != nil || name != "key" {
					kept = append(kept, part)
					continue
				}
				removed = true
				if value, err := url.QueryUnescape(rawValue); err == nil && queryKey == "" {
					queryKey = value
				}
			}
			if removed {
				c.Request.URL.RawQuery = strings.Join(kept, "&")
				c.Request.RequestURI = c.Request.URL.RequestURI()
			}
			headerKey := header.Get("X-Goog-Api-Key")
			header.Del("X-Goog-Api-Key")
			if header.Get("Authorization") == "" {
				if headerKey != "" {
					header.Set("Authorization", "Bearer "+headerKey)
				} else if queryKey != "" {
					header.Set("Authorization", "Bearer "+queryKey)
				}
			}
		}
		c.Next()
	}
}

// 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
		}
		candidates := pinned.Candidates
		if len(candidates) == 0 {
			candidates = []pluginruntime.RouteBinding{{Plugin: pinned.Plugin, Route: pinned.Route}}
		}
		logger.LogDebug(
			c,
			"task_plugin subsystem=route event=prepare_start generation=%d plugin=%q method=%q declared_type=%q candidates=%d",
			generation,
			pinned.Plugin.Meta.Key,
			pinned.Route.Method,
			pinned.Route.Type,
			len(candidates),
		)

		requestContext, err := buildTaskPluginRouteRequest(c)
		requestContext.Params = pluginruntime.RouteParams(c.Params, pinned.Route)
		c.Set(pluginruntime.ContextKeyRouteRequest, requestContext)
		if err != nil {
			logger.LogWarn(
				c,
				common.LogText("task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=invalid_request err=%q",
					generation,
					pinned.Plugin.Meta.Key,
					err.Error()),
			)
			status := invalidRequestStatus(err)
			if errors.Is(err, errTaskPluginUnsupportedMediaType) {
				status = http.StatusUnsupportedMediaType
			}
			abortTaskPluginRouteErrorDetail(c, status, err.Error())
			return
		}
		bodyObject, _ := requestContext.Body.(map[string]any)
		bodyKind, _ := bodyObject["kind"].(string)
		// Submit routes hand JSON and multipart bodies to the decoder, which
		// rejects the kinds its vendor format does not use. A route scoped to
		// models reads the model from a JSON body, so it takes JSON only.
		// Query routes take no body.
		eligible := make([]pluginruntime.RouteBinding, 0, len(candidates))
		for _, candidate := range candidates {
			submitBody := bodyKind == string(pluginruntime.BodyJSON) || bodyKind == string(pluginruntime.BodyMultipart) && len(candidate.Route.Models) == 0
			if candidate.Route.Type == pluginruntime.RouteTypeQuery && bodyKind == string(pluginruntime.BodyNone) || candidate.Route.Type != pluginruntime.RouteTypeQuery && submitBody {
				eligible = append(eligible, candidate)
			}
		}
		if len(eligible) == 0 {
			logger.LogWarn(
				c,
				common.LogText("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 or multipart body"
			if len(pinned.Route.Models) > 0 {
				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 eligible[0].Route.Type == pluginruntime.RouteTypeQuery {
			renderTaskPluginRouteQuery(c, pinned, requestContext, eligible)
			return
		}

		// Every candidate decodes the request; the first that accepts it in
		// key order names the model, and the candidates that accept the same
		// model stay eligible for distribution, which picks the executing
		// plugin by channel.
		claimedModel := ""
		if bodyValue, isObject := bodyObject["value"].(map[string]any); isObject {
			claimedModel, _ = bodyValue["model"].(string)
		}
		hookStarted := time.Now()
		var (
			accepted         []pluginruntime.RouteBinding
			resolved         map[string]any
			resolvedIntent   taskPluginRouteIntent
			resolvedContext  pluginruntime.RouteRequestContext
			resolvedBodyText json.RawMessage
			queryMissed      bool
			failures         []string
		)
		rejectedPlugins := make(map[string][]string)
		for _, candidate := range eligible {
			candidateContext := requestContext
			candidateContext.Params = pluginruntime.RouteParams(c.Params, candidate.Route)
			var (
				result   map[string]any
				bodyText json.RawMessage
				intent   taskPluginRouteIntent
				reason   string
				detail   string
			)
			// The list names declared models; a case variant or a channel
			// alias of a listed model is allowed with it.
			allowed := len(candidate.Route.Models) == 0
			if !allowed {
				_, _, allowed = model.ResolveTaskPluginModel(pinned.Generation, claimedModel, candidate.Route.Models)
			}
			if !allowed {
				reason, detail = "model_not_allowed", fmt.Sprintf("model %q is not allowed on this route", claimedModel)
			} else {
				value, text, callErr := candidate.Plugin.Engine.CallPathWithMemberJSON(
					c.Request.Context(), 0, candidate.Plugin.Meta.JSONTextMember("requestBody"),
					"native", []string{candidate.Route.Decode}, candidateContext.JSValueFor(candidate.Plugin.Meta),
				)
				if callErr != nil {
					reason, detail = "hook_failed", taskPluginHookDetail(callErr)
				} else {
					result, _ = value.(map[string]any)
					bodyText = text
					intent, reason, detail = checkTaskPluginRouteIntent(pinned.Generation, candidate, value)
				}
			}
			if reason == "" && intent.kind == string(pluginruntime.RouteTypeQuery) {
				if len(eligible) == 1 {
					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,
						candidate.Plugin.Meta.Key,
						candidate.Route.Render,
						len(intent.taskIDs),
						time.Since(hookStarted).Milliseconds(),
					)
					renderTaskPluginQuery(c, pinned, candidateContext, intent.taskIDs, candidate.Route.Render, true)
					return
				}
				owned, lookupErr := taskPluginOwnsTasks(c, candidate.Plugin.Meta, intent.taskIDs)
				if lookupErr != nil {
					abortTaskPluginRouteError(c, http.StatusInternalServerError)
					return
				}
				if owned {
					pinned.Plugin, pinned.Route = candidate.Plugin, candidate.Route
					c.Set(pluginruntime.ContextKeyPinnedRoute, pinned)
					c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Generation: pinned.Generation, Plugin: candidate.Plugin})
					c.Set(pluginruntime.ContextKeyRouteRequest, candidateContext)
					renderTaskPluginQuery(c, pinned, candidateContext, intent.taskIDs, candidate.Route.Render, true)
					return
				}
				queryMissed = true
				continue
			}
			if reason == "" && resolved != nil && intent.model != resolvedIntent.model {
				logger.LogDebug(c, "task_plugin subsystem=route event=candidate_skipped generation=%d plugin=%q reason=other_model model=%q", generation, candidate.Plugin.Meta.Key, intent.model)
				continue
			}
			if reason != "" {
				if detail == "" {
					detail = "Invalid request"
				}
				logger.LogWarn(
					c,
					common.LogText("task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=resolve_request reason=%s err=%q elapsed_ms=%d",
						generation,
						candidate.Plugin.Meta.Key,
						reason,
						detail,
						time.Since(hookStarted).Milliseconds()),
				)
				if _, seen := rejectedPlugins[detail]; !seen {
					failures = append(failures, detail)
				}
				rejectedPlugins[detail] = append(rejectedPlugins[detail], candidate.Plugin.Meta.Key)
				continue
			}
			accepted = append(accepted, candidate)
			if resolved == nil {
				resolved, resolvedIntent, resolvedContext, resolvedBodyText = result, intent, candidateContext, bodyText
			}
		}
		if len(accepted) == 0 {
			if queryMissed {
				abortTaskPluginRouteError(c, http.StatusNotFound)
				return
			}
			if len(eligible) > 1 {
				for index, detail := range failures {
					failures[index] = strings.Join(rejectedPlugins[detail], ", ") + ": " + detail
				}
			}
			abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, strings.Join(failures, "; "))
			return
		}

		owner := accepted[0]
		pinned.Plugin, pinned.Route, pinned.Candidates = owner.Plugin, owner.Route, accepted
		c.Set(pluginruntime.ContextKeyPinnedRoute, pinned)
		c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Generation: pinned.Generation, Plugin: owner.Plugin})
		_, bodyReplaced := resolved["requestBody"]
		if bodyReplaced {
			resolvedContext.RequestBody = resolved["requestBody"]
		}
		c.Set(pluginruntime.ContextKeyRouteRequest, resolvedContext)
		c.Set("task_request", resolvedContext.RequestBody)
		if resolvedBodyText != nil {
			c.Set(pluginruntime.ContextKeyRequestBodyText, resolvedBodyText)
		}
		c.Set("resolved_task_model", resolvedIntent.model)
		c.Set("expected_task_plugin_key", owner.Plugin.Meta.Key)
		c.Set("task_plugin_key", owner.Plugin.Meta.Key)
		c.Set("platform", owner.Plugin.Meta.Key)
		service.AppendTaskPluginIdentityFilter(c, owner.Plugin.Meta.Key)
		if resolvedIntent.action != "" {
			c.Set("task_action", resolvedIntent.action)
		}
		c.Set("relay_mode", relayconstant.RelayModeVideoSubmit)
		if intentErr := applyOriginTaskIntent(c, resolved, owner.Plugin.Meta); intentErr != nil {
			logger.LogWarn(
				c,
				common.LogText("task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=origin_task reason=%s",
					generation,
					owner.Plugin.Meta.Key,
					intentErr.Code),
			)
			abortTaskPluginRouteErrorDetail(c, intentErr.StatusCode, intentErr.Message)
			return
		}
		logger.LogDebug(
			c,
			"task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=submit model=%q action_present=%t request_body_replaced=%t candidates=%d distribute=true elapsed_ms=%d",
			generation,
			owner.Plugin.Meta.Key,
			resolvedIntent.model,
			resolvedIntent.action != "",
			bodyReplaced,
			len(accepted),
			time.Since(hookStarted).Milliseconds(),
		)
		c.Next()
	}
}

// taskPluginRouteIntent is what a native route's decoder resolved: a submit
// (model and action) or, on a dynamic route, a query (task ids).
type taskPluginRouteIntent struct {
	kind    string
	model   string
	action  string
	taskIDs []string
}

// checkTaskPluginRouteIntent validates a native decoder's result against the
// candidate route. A rejection names a log reason and the client's detail.
func checkTaskPluginRouteIntent(generation *pluginruntime.RoutingGeneration, candidate pluginruntime.RouteBinding, value any) (intent taskPluginRouteIntent, reason, detail string) {
	resolved, ok := value.(map[string]any)
	if !ok {
		return intent, "result_not_object", taskPluginInvalidRouteResult
	}
	kind, ok := resolved["kind"].(string)
	if !ok {
		return intent, "missing_kind", taskPluginInvalidRouteResult
	}
	if _, forbidden := resolved["renderer"]; forbidden {
		return intent, "forbidden_renderer", taskPluginInvalidRouteResult
	}
	intent.kind = kind
	switch kind {
	case string(pluginruntime.RouteTypeSubmit):
		modelName, valid := resolved["model"].(string)
		if !valid || strings.TrimSpace(modelName) == "" {
			return intent, "invalid_model", "decoded request is missing a model"
		}
		// The task is named as on the host protocols: a case variant by the
		// declared spelling, a channel alias by the alias.
		served, declared, owned := model.ResolveTaskPluginModel(generation, modelName, candidate.Plugin.Meta.Models)
		if !owned {
			return intent, "model_not_owned", fmt.Sprintf("model %q is not served by this plugin", modelName)
		}
		if len(candidate.Route.Models) > 0 && !slices.Contains(candidate.Route.Models, declared) {
			return intent, "resolved_model_not_allowed", fmt.Sprintf("model %q is not allowed on this route", modelName)
		}
		intent.model = served
		intent.action = candidate.Route.Action
		if resolvedAction, present := resolved["action"]; present {
			actionValue, actionOK := resolvedAction.(string)
			if !actionOK {
				return intent, "invalid_action", taskPluginInvalidRouteResult
			}
			if strings.TrimSpace(actionValue) != "" {
				intent.action = actionValue
			}
		}
	case string(pluginruntime.RouteTypeQuery):
		if candidate.Route.Type != pluginruntime.RouteTypeDynamic {
			return intent, "query_from_non_dynamic_route", taskPluginInvalidRouteResult
		}
		taskIDs, valid := resolvedTaskPluginIDs(resolved["taskIds"])
		if !valid {
			return intent, "invalid_query_result", taskPluginInvalidRouteResult
		}
		intent.taskIDs = taskIDs
	default:
		return intent, "unsupported_kind", taskPluginInvalidRouteResult
	}
	return intent, "", ""
}

// renderTaskPluginRouteQuery answers a native query route. A candidate reads
// the task id from its path parameter or, when its taskIdParam is not in the
// path, from the query string. On a path several plugins declare, the task's
// own plugin answers: the first candidate in key order that owns it. A task
// none of them owns is not found, like a missing one.
func renderTaskPluginRouteQuery(c *gin.Context, pinned pluginruntime.PinnedRoute, requestContext pluginruntime.RouteRequestContext, candidates []pluginruntime.RouteBinding) {
	taskIDs := make([]string, len(candidates))
	for index, candidate := range candidates {
		if !candidate.Route.TaskIDInQuery() {
			taskIDs[index] = pluginruntime.RouteParams(c.Params, candidate.Route)[candidate.Route.TaskIDParam]
		} else if values := requestContext.Query[candidate.Route.TaskIDParam]; len(values) > 0 {
			taskIDs[index] = strings.TrimSpace(values[0])
		}
	}
	if !slices.ContainsFunc(taskIDs, func(taskID string) bool { return taskID != "" }) {
		abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, candidates[0].Route.TaskIDParam+" is required")
		return
	}
	ownerIndex := 0
	if len(candidates) > 1 {
		ownerIndex = -1
		platforms := make([]constant.TaskPlatform, 0, len(candidates))
		for index, candidate := range candidates {
			if taskIDs[index] != "" {
				platforms = append(platforms, taskPluginLegacyPlatforms(candidate.Plugin.Meta)...)
			}
		}
		tasks, err := model.GetByTaskIdsForPlatforms(common.GetContextKeyInt(c, constant.ContextKeyUserId), platforms, slices.Compact(slices.Sorted(slices.Values(taskIDs))))
		if err != nil {
			abortTaskPluginRouteError(c, http.StatusInternalServerError)
			return
		}
		for index, candidate := range candidates {
			if slices.ContainsFunc(tasks, func(task *model.Task) bool {
				return task.TaskID == taskIDs[index] && task.ResultRetrievable() && slices.Contains(taskPluginLegacyPlatforms(candidate.Plugin.Meta), task.Platform)
			}) {
				ownerIndex = index
				break
			}
		}
		if ownerIndex < 0 {
			abortTaskPluginRouteError(c, http.StatusNotFound)
			return
		}
	}
	owner := candidates[ownerIndex]
	if taskIDs[ownerIndex] == "" {
		abortTaskPluginRouteErrorDetail(c, http.StatusBadRequest, owner.Route.TaskIDParam+" is required")
		return
	}
	requestContext.Params = pluginruntime.RouteParams(c.Params, owner.Route)
	pinned.Plugin, pinned.Route = owner.Plugin, owner.Route
	c.Set(pluginruntime.ContextKeyPinnedRoute, pinned)
	c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Generation: pinned.Generation, Plugin: owner.Plugin})
	c.Set(pluginruntime.ContextKeyRouteRequest, requestContext)
	generation := uint64(0)
	if pinned.Generation != nil {
		generation = pinned.Generation.Number
	}
	logger.LogDebug(
		c,
		"task_plugin subsystem=route event=resolved generation=%d plugin=%q kind=query renderer=%q task_count=1 candidates=%d distribute=false",
		generation,
		owner.Plugin.Meta.Key,
		owner.Route.Render,
		len(candidates),
	)
	renderTaskPluginQuery(c, pinned, requestContext, []string{taskIDs[ownerIndex]}, owner.Route.Render, false)
}

// taskPluginOwnsTasks reports whether every task id names a retrievable task
// of the caller on one of the plugin's platforms.
func taskPluginOwnsTasks(c *gin.Context, meta pluginruntime.Meta, taskIDs []string) (bool, error) {
	tasks, err := model.GetByTaskIdsForPlatforms(common.GetContextKeyInt(c, constant.ContextKeyUserId), taskPluginLegacyPlatforms(meta), taskIDs)
	if err != nil {
		return false, err
	}
	for _, taskID := range taskIDs {
		if !slices.ContainsFunc(tasks, func(task *model.Task) bool { return task.TaskID == taskID && task.ResultRetrievable() }) {
			return false, nil
		}
	}
	return true, nil
}

// RouteTaskPluginEndpoint selects between ordinary relay and plugin channels
// before a plugin can validate or rewrite the client's request. Plugin calls
// still prepare their candidates and origin-task pins before final distribution.
func RouteTaskPluginEndpoint() gin.HandlerFunc {
	pinEndpoint := PinTaskPluginEndpoint()
	return func(c *gin.Context) {
		generation := pluginruntime.DefaultRegistry.Generation()
		request, err := getModelFromRequest(c)
		if err != nil || generation == nil {
			pinEndpoint(c)
			return
		}
		endpoint, found := lookupTaskPluginEndpoint(generation, c.Request.Method, c.Request.URL.Path, request.Model)
		if !found {
			c.Set(contextKeyTaskPluginEndpointModel, *request)
			c.Next()
			return
		}

		filter := dto.ChannelFilter{Kind: dto.FilterTaskPluginEndpoint}
		for _, candidate := range endpoint.Candidates {
			filter.TaskPluginKeys = append(filter.TaskPluginKeys, candidate.Plugin.Meta.Key)
			filter.TaskPluginChannelTypes = append(filter.TaskPluginChannelTypes, candidate.Plugin.Meta.ChannelTypes...)
		}
		constraints := service.GetChannelConstraints(c)
		filterIndex := len(constraints.Filters)
		constraints.AddFilter(filter)
		constraints.AddFilter(dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: c.Request.URL.Path})
		group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
		channel, _, selectErr := service.SelectChannelForRequest(c, request.Model, &service.RetryParam{
			Ctx: c, ModelName: request.Model, TokenGroup: group,
			RequestPath: c.Request.URL.Path, Retry: common.GetPointer(0),
		})
		c.Set(contextKeyTaskPluginEndpointSelection, taskPluginEndpointSelection{
			Generation: generation, Channel: channel, Model: request.Model,
		})
		if selectErr != nil {
			// Keep the existing plugin diagnostics and canonical-model lookup
			// when selection cannot resolve the client's original spelling.
			pinEndpoint(c)
			return
		}
		filter.Kind = dto.FilterTaskPluginIdentity
		filter.TaskPluginKey = endpoint.Plugin.Meta.Key
		if pluginChannel, _ := model.ChannelSatisfiesFilters(channel, request.Model, []dto.ChannelFilter{filter}); !pluginChannel {
			constraints.Filters[filterIndex].TaskPluginRelayOnly = true
			c.Set(pluginruntime.ContextKeyRelayEndpoint, endpoint)
			c.Set(contextKeyTaskPluginEndpointModel, *request)
			c.Next()
			return
		}
		pinEndpoint(c)
	}
}

func lookupTaskPluginEndpoint(generation *pluginruntime.RoutingGeneration, method, path, modelName string) (pluginruntime.PinnedEndpoint, bool) {
	lookupModel, pinModel, mappedModel := modelName, modelName, ""
	if declared, ok := generation.CanonicalModel(modelName); ok {
		lookupModel, pinModel = declared, declared
	} else if target, ok := model.ResolveTaskModelAlias(generation, modelName); ok {
		if target.Declared == "" {
			return pluginruntime.PinnedEndpoint{}, false
		}
		lookupModel, pinModel, mappedModel = target.Declared, target.Alias, target.Declared
	}
	candidates := generation.LookupEndpointCandidates(method, path, lookupModel)
	if len(candidates) == 0 {
		return pluginruntime.PinnedEndpoint{}, false
	}
	binding := candidates[0]
	return pluginruntime.PinnedEndpoint{
		Generation: generation, Plugin: binding.Plugin, Protocol: binding.Protocol,
		Operation: binding.Operation, Model: pinModel, MappedModel: mappedModel, Candidates: candidates,
	}, true
}

// 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 value, exists := c.Get(contextKeyTaskPluginEndpointSelection); exists {
			if selected, ok := value.(taskPluginEndpointSelection); ok {
				generation = selected.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, invalidRequestStatus(err), i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
				return
			}
			c.Next()
			return
		}
		claimedModel := modelRequest.Model
		if strings.TrimSpace(claimedModel) == "" {
			c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
			c.Next()
			return
		}
		pinnedEndpoint, found := lookupTaskPluginEndpoint(generation, c.Request.Method, c.Request.URL.Path, claimedModel)
		if !found {
			c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
			c.Next()
			return
		}
		if claimedModel != pinnedEndpoint.Model {
			if rewriteErr := rewriteTaskPluginJSONModel(c, pinnedEndpoint.Model); rewriteErr != nil {
				abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid task protocol request")
				return
			}
		}
		modelRequest.Model = pinnedEndpoint.Model
		c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
		candidates := pinnedEndpoint.Candidates
		binding := candidates[0]
		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.Plugin = binding.Plugin
		pinnedEndpoint.Protocol = binding.Protocol
		pinnedEndpoint.Operation = binding.Operation
		pinnedEndpoint.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,
			pinnedEndpoint.Model,
		)
		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 := ojson.GetManyBytes(requestBody, "stream", "background")
	return values[0].Type == ojson.True, values[1].Type == ojson.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
// candidates pinned before distribution, retaining only plugins that accept it.
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,
				common.LogText("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,
				common.LogText("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,
				common.LogText("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,
						common.LogText("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,
		}
		hookStarted := time.Now()
		candidates := pinned.Candidates
		if len(candidates) == 0 {
			candidates = []pluginruntime.ProtocolBinding{{Plugin: pinned.Plugin, Protocol: pinned.Protocol, Operation: pinned.Operation, Model: pinned.Model}}
		}
		accepted := make([]pluginruntime.ProtocolBinding, 0, len(candidates))
		var resolved map[string]any
		var resolvedBodyText json.RawMessage
		var failures []string
		rejectedPlugins := make(map[string][]string)
		for _, candidate := range candidates {
			candidateContext := protocolContext
			candidateContext.Protocol = candidate.Protocol
			candidateContext.Operation = candidate.Operation.Name
			// Parsing belongs to durable submission; disconnecting only stops
			// the later Responses observation.
			resolvedValue, requestBodyText, callErr := candidate.Plugin.Engine.CallPathWithMemberJSON(
				context.WithoutCancel(c.Request.Context()), pluginruntime.DefaultCallTimeout,
				candidate.Plugin.Meta.JSONTextMember("requestBody"),
				"protocols", []string{candidate.Protocol, "decodeRequest"}, candidateContext.JSValueFor(candidate.Plugin.Meta),
			)
			result, resultOK := resolvedValue.(map[string]any)
			detail := ""
			reason := ""
			if callErr != nil {
				reason = "hook_failed"
				detail = taskPluginHookDetail(callErr)
				if detail == "" {
					detail = "Invalid task protocol request"
				}
			} else if !resultOK {
				reason = "result_not_object"
				detail = taskPluginInvalidRouteResult
			} else if kind, _ := result["kind"].(string); kind != string(pluginruntime.RouteTypeSubmit) {
				reason = "unsupported_kind"
				detail = taskPluginInvalidRouteResult
			} else if model, _ := result["model"].(string); strings.TrimSpace(model) == "" {
				reason = "invalid_model"
				detail = "decoded request is missing a model"
			} else if model != pinned.Model || (pinned.MappedModel == "" && !slices.Contains(candidate.Plugin.Meta.Models, model)) {
				reason = "resolved_model_not_owned"
				detail = fmt.Sprintf("model %q is not served by this plugin", model)
			}
			if detail != "" {
				logger.LogWarn(c, common.LogText("task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=%s err=%q elapsed_ms=%d",
					pinned.Generation.Number, candidate.Plugin.Meta.Key, reason, detail, time.Since(hookStarted).Milliseconds()))
				if _, seen := rejectedPlugins[detail]; !seen {
					failures = append(failures, detail)
				}
				rejectedPlugins[detail] = append(rejectedPlugins[detail], candidate.Plugin.Meta.Key)
				continue
			}
			accepted = append(accepted, candidate)
			if resolved == nil {
				resolved = result
				resolvedBodyText = requestBodyText
				protocolContext = candidateContext
			}
		}
		if len(accepted) == 0 {
			if len(candidates) > 1 {
				for index, detail := range failures {
					failures[index] = strings.Join(rejectedPlugins[detail], ", ") + ": " + detail
				}
			}
			abortWithOpenAiMessage(c, http.StatusBadRequest, strings.Join(failures, "; "))
			return
		}
		pinned.Candidates = accepted
		pinned.Plugin = accepted[0].Plugin
		pinned.Protocol = accepted[0].Protocol
		pinned.Operation = accepted[0].Operation
		c.Set(pluginruntime.ContextKeyPinnedEndpoint, pinned)
		c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Generation: pinned.Generation, Plugin: pinned.Plugin})
		c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
		resolvedModel := pinned.Model

		action := ""
		if resolvedAction, present := resolved["action"]; present {
			action, ok = resolvedAction.(string)
			if !ok {
				logger.LogWarn(
					c,
					common.LogText("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)
		if resolvedBodyText != nil {
			c.Set(pluginruntime.ContextKeyRequestBodyText, resolvedBodyText)
		}
		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,
				common.LogText("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),
		Headers: taskjsplugin.ClientRequestHeaders(c.Request.Header),
		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}
		// Body storage never changes its bytes in place (a rewrite stores a
		// new copy), so the text needs no copy of its own.
		requestContext.BodyText = raw
	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(part)
				part.Close()
				if readErr != nil {
					return requestContext, readErr
				}
				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 index, 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 := pluginruntime.FileReference(field, index)
				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)
			}
		}
	}
	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([]dto.TaskView, 0, len(taskIDs))
	for _, taskID := range taskIDs {
		task := tasksByID[taskID]
		if task == nil || !task.ResultRetrievable() {
			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
		}
		views = append(views, view)
	}

	var rendererInput any = views
	if !multiple {
		rendererInput = views[0]
	}
	encoded, err := common.Marshal(rendererInput)
	if err != nil {
		abortTaskPluginRouteError(c, http.StatusInternalServerError)
		return
	}
	// The presenter of one finished task gets its artifacts as the gateway
	// addresses host protocol renderers get.
	presenterContext := requestContext.JSValue()
	artifacts := map[string]any{}
	if !multiple {
		projected, projectErr := taskjsplugin.RendererArtifacts(pinned.Plugin, tasksByID[taskIDs[0]], service.BuildTaskArtifactContentURL)
		if projectErr != nil {
			logger.LogWarn(c, common.LogText("task_plugin subsystem=query event=artifacts_unavailable generation=%d plugin=%q err=%q", generation, pinned.Plugin.Meta.Key, projectErr.Error()))
		} else {
			artifacts = projected
		}
	}
	presenterContext["artifacts"] = artifacts
	renderStarted := time.Now()
	result, err := pinned.Plugin.Engine.CallPath(c.Request.Context(), "native", []string{renderer}, presenterContext, pluginruntime.RawJSON(encoded))
	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(),
		)
		// A presenter with nothing to deliver for the task, such as a file
		// it has no artifact for, answers like a missing task.
		status := http.StatusInternalServerError
		if taskPluginHookDetail(err) == "artifact_not_found" {
			status = http.StatusNotFound
		}
		abortTaskPluginRouteError(c, status)
		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()
	if replyErr := WriteNativeReply(c, http.StatusOK, result); replyErr != nil {
		logger.LogWarn(c, common.LogText("task_plugin subsystem=query event=render_failed generation=%d plugin=%q renderer=%q err=%q", generation, pinned.Plugin.Meta.Key, renderer, replyErr.Error()))
		abortTaskPluginRouteError(c, http.StatusInternalServerError)
	}
}

// nativeReplyHeaders are the headers a reply wrapper may set: a redirect
// target and a retry hint.
var nativeReplyHeaders = []string{"Location", "Retry-After"}

// WriteNativeReply writes what a native presenter returned. A value is the
// JSON body of a reply with the given status (200 for submit and query
// presenters, the host's status for the error presenter), unless it is an
// object with a __status member: such a reply wrapper, {__status, __headers?,
// __body?}, answers with the vendor's own status, as xAI answers a pending
// video with 202. The status is an integer from 200 to 599, __headers may set
// only Location and Retry-After, and a wrapper without __body sends no body.
// An invalid wrapper is an error and writes nothing.
func WriteNativeReply(c *gin.Context, status int, value any) error {
	wrapper, ok := value.(map[string]any)
	if !ok {
		c.JSON(status, value)
		return nil
	}
	rawStatus, isWrapper := wrapper["__status"]
	if !isWrapper {
		c.JSON(status, value)
		return nil
	}
	status = 0
	switch typed := rawStatus.(type) {
	case int64:
		status = int(typed)
	case int:
		status = typed
	case float64:
		if typed == float64(int(typed)) {
			status = int(typed)
		}
	}
	if status < 200 || status > 599 {
		return fmt.Errorf("reply wrapper __status must be an integer from 200 to 599")
	}
	headers := map[string]string{}
	for key, member := range wrapper {
		switch key {
		case "__status", "__body":
		case "__headers":
			values, ok := member.(map[string]any)
			if !ok {
				return fmt.Errorf("reply wrapper __headers must be an object")
			}
			for name, headerValue := range values {
				canonical := http.CanonicalHeaderKey(name)
				text, isString := headerValue.(string)
				if !slices.Contains(nativeReplyHeaders, canonical) || !isString {
					return fmt.Errorf("reply wrapper may set only string Location and Retry-After headers")
				}
				headers[canonical] = text
			}
		default:
			return fmt.Errorf("reply wrapper has unknown member %q", key)
		}
	}
	for name, headerValue := range headers {
		c.Header(name, headerValue)
	}
	if body, hasBody := wrapper["__body"]; hasBody {
		c.JSON(status, body)
		return nil
	}
	c.Status(status)
	c.Writer.WriteHeaderNow()
	return nil
}

// RespondTaskPluginError gives a pinned plugin a sanitized error DTO and writes
// its native error body with the host's status, unless the body is a reply
// wrapper that answers with the vendor's own status.
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 {
			callErr = WriteNativeReply(c, sanitized.HTTPStatus, body)
		}
		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,
			)
			return true
		}
		logger.LogWarn(
			c,
			common.LogText("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,
		common.LogText("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"
	switch channel.Type {
	case constant.ChannelTypeTaskPlugin:
		identityMode = "type59_setting"
	case constant.ChannelTypeNewAPI:
		identityMode = "type60_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(invalidRequestStatus(err), 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)
		if plugin.Meta.PreservesJSONOrder() {
			// The body as stored after any model rewrite: the text hooks get.
			storage, err := common.GetBodyStorage(c)
			var raw []byte
			if err == nil {
				raw, err = storage.Bytes()
			}
			if err != nil {
				c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": err.Error(), "type": "invalid_request_error"}})
				return
			}
			c.Set(pluginruntime.ContextKeyRequestBodyText, json.RawMessage(raw))
		}
		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()
	}
}