Files
new-api/controller/plugin_protocol.go
T
CaIon 2ffc595901 feat(jsplugin): run task plugins on moejs
Task plugins now run on github.com/Calcium-Ion/moejs v0.1.0-alpha.3
instead of Sobek, and the host utils are ported to it. The plugin API is
unchanged. Code compiled from strings is no longer allowed: eval and the
Function constructors throw an EvalError.

Hooks receive host values without defensive copies. The engine converts
an object or array when the hook first reads it and never writes
JavaScript changes back to the host value. Stored protocol state and task
views are passed as raw JSON and parsed inside the runtime instead of
being decoded into Go maps first. An idle runtime drops its references to
the last call's arguments.

Whether an optional hook is exported is decided once when the plugin
loads, so checking for it no longer takes an execution slot.

Usage validation returns the normalized facts in a new map instead of
writing into the hook result, so a hook that returns the request body no
longer changes it. The request descriptor is copied instead of being
round-tripped through JSON.
2026-09-30 17:24:19 +08:00

1296 lines
45 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"hash/fnv"
"net/http"
"strconv"
"strings"
"time"
"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"
"github.com/QuantumNous/new-api/relay"
taskjsplugin "github.com/QuantumNous/new-api/relay/channel/task/jsplugin"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
type pluginProtocolBridgeDeps struct {
submit func(*gin.Context, *relaycommon.RelayInfo) (*taskSubmissionOutcome, *dto.TaskError)
loadTask func(context.Context, int, constant.TaskPlatform, string) (*model.Task, bool, error)
now func() time.Time
admissions *pluginProtocolObservationLimiter
protocolLimits relay.PluginProtocolLimits
artifactContentURL func(taskID, artifactKey string) (string, error)
submissionTimeout time.Duration
observationTimeout time.Duration
loadTimeout time.Duration
tickInterval time.Duration
tickJitter time.Duration
heartbeatInterval time.Duration
admissionTimeout time.Duration
getByTaskId func(int, string) (*model.Task, bool, error)
resolvePlugin func(constant.TaskPlatform) (*pluginruntime.LoadedPlugin, *pluginruntime.RoutingGeneration, bool)
// imagePollInterval, pollTask and downloadImage serve the synchronous
// OpenAI Images protocol: an asynchronous upstream image task is polled
// inside the request, and b64_json responses download each image URL.
imagePollInterval time.Duration
pollTask func(context.Context, *model.Task) error
downloadImage func(url string) (mimeType string, base64Data string, err error)
}
// taskPluginImagePollInterval paces the in-request polling of asynchronous
// image tasks. Legacy DashScope text-to-image jobs finish within tens of
// seconds, so a short interval keeps the synchronous response prompt.
const taskPluginImagePollInterval = 3 * time.Second
func defaultPluginProtocolBridgeDeps() pluginProtocolBridgeDeps {
timeout := time.Duration(constant.TaskPluginProtocolTimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 10 * time.Minute
}
tick := time.Duration(constant.TaskPluginProtocolTickMilliseconds) * time.Millisecond
if tick <= 0 {
tick = 2 * time.Second
}
jitter := max(time.Duration(constant.TaskPluginProtocolTickJitterMilliseconds)*time.Millisecond, 0)
heartbeat := time.Duration(constant.TaskPluginProtocolHeartbeatSeconds) * time.Second
if heartbeat <= 0 {
heartbeat = 15 * time.Second
}
loadTimeout := 5 * time.Second
if halfHeartbeat := heartbeat / 2; halfHeartbeat > 0 && halfHeartbeat < loadTimeout {
loadTimeout = halfHeartbeat
}
return pluginProtocolBridgeDeps{
submit: executeTaskSubmission,
loadTask: model.GetTaskForProtocolObservation,
now: time.Now,
admissions: pluginProtocolObservationAdmissions,
protocolLimits: relay.DefaultPluginProtocolLimits(),
artifactContentURL: service.BuildTaskArtifactContentURL,
submissionTimeout: timeout,
observationTimeout: timeout,
loadTimeout: loadTimeout,
tickInterval: tick,
tickJitter: jitter,
heartbeatInterval: heartbeat,
admissionTimeout: pluginruntime.DefaultCallTimeout,
getByTaskId: model.GetByTaskId,
resolvePlugin: resolveTaskPluginForProtocolRetrieve,
imagePollInterval: taskPluginImagePollInterval,
pollTask: pollTaskPluginImageTask,
downloadImage: service.GetImageFromUrl,
}
}
func (d pluginProtocolBridgeDeps) withDefaults() pluginProtocolBridgeDeps {
defaults := defaultPluginProtocolBridgeDeps()
if d.submit == nil {
d.submit = defaults.submit
}
if d.loadTask == nil {
d.loadTask = defaults.loadTask
}
if d.now == nil {
d.now = defaults.now
}
if d.admissions == nil {
d.admissions = defaults.admissions
}
if d.artifactContentURL == nil {
d.artifactContentURL = defaults.artifactContentURL
}
if d.submissionTimeout <= 0 {
d.submissionTimeout = defaults.submissionTimeout
}
if d.observationTimeout <= 0 {
d.observationTimeout = defaults.observationTimeout
}
if d.loadTimeout <= 0 {
d.loadTimeout = defaults.loadTimeout
}
if d.tickInterval <= 0 {
d.tickInterval = defaults.tickInterval
}
if d.tickJitter < 0 {
d.tickJitter = 0
}
if d.heartbeatInterval <= 0 {
d.heartbeatInterval = defaults.heartbeatInterval
}
if halfHeartbeat := d.heartbeatInterval / 2; halfHeartbeat > 0 && d.loadTimeout > halfHeartbeat {
d.loadTimeout = halfHeartbeat
}
if d.admissionTimeout <= 0 {
d.admissionTimeout = defaults.admissionTimeout
}
if d.getByTaskId == nil {
d.getByTaskId = defaults.getByTaskId
}
if d.resolvePlugin == nil {
d.resolvePlugin = defaults.resolvePlugin
}
if d.imagePollInterval <= 0 {
d.imagePollInterval = defaults.imagePollInterval
}
if d.pollTask == nil {
d.pollTask = defaults.pollTask
}
if d.downloadImage == nil {
d.downloadImage = defaults.downloadImage
}
return d
}
func resolveTaskPluginForProtocolRetrieve(platform constant.TaskPlatform) (*pluginruntime.LoadedPlugin, *pluginruntime.RoutingGeneration, bool) {
generation := pluginruntime.DefaultRegistry.Generation()
plugin, ok := relay.ResolveTaskPluginForPlatform(generation, platform)
return plugin, generation, ok
}
func serveTaskPluginProtocol(
c *gin.Context,
pinned pluginruntime.PinnedEndpoint,
deps pluginProtocolBridgeDeps,
) {
deps = deps.withDefaults()
generation := uint64(0)
if pinned.Generation != nil {
generation = pinned.Generation.Number
}
pluginKey := ""
if pinned.Plugin != nil {
pluginKey = pinned.Plugin.Meta.Key
}
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=bridge_start generation=%d plugin=%q protocol=%q model=%q",
generation,
pluginKey,
pinned.Protocol,
c.GetString("resolved_task_model"),
)
if !pluginruntime.SupportsHostProtocol(pinned.Protocol) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=bridge_rejected generation=%d plugin=%q reason=unsupported_protocol", generation, pluginKey)
respondPluginProtocolError(c, http.StatusNotImplemented, "task_protocol_not_available", "Task protocol bridge is not available")
return
}
requestValue, exists := c.Get(pluginruntime.ContextKeyProtocolRequest)
protocolRequest, ok := requestValue.(pluginruntime.ProtocolRequestContext)
if !exists || !ok || protocolRequest.Protocol != pinned.Protocol {
logger.LogDebug(c, "task_plugin subsystem=protocol event=bridge_rejected generation=%d plugin=%q reason=invalid_protocol_context", generation, pluginKey)
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
if definition, known := pluginruntime.HostProtocol(pinned.Protocol); known && len(definition.DefinedModes()) > 0 && pinned.Plugin != nil {
background := false
if body, ok := protocolRequest.Body.(map[string]any); ok && body["kind"] == string(pluginruntime.BodyJSON) {
if requestBody, ok := body["value"].(map[string]any); ok {
background, _ = requestBody["background"].(bool)
}
}
missing := false
if protocolRequest.Stream && !pinned.Plugin.Meta.ProtocolSupports(pinned.Protocol, "stream") {
missing = true
}
if background && !pinned.Plugin.Meta.ProtocolSupports(pinned.Protocol, "background") {
missing = true
}
if !protocolRequest.Stream && !background && !pinned.Plugin.Meta.ProtocolSupports(pinned.Protocol, "sync") {
missing = true
}
if missing {
logger.LogError(c, "pinned task plugin does not support the requested protocol form")
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
}
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=request_ready generation=%d plugin=%q protocol=%q stream=%t",
generation,
pluginKey,
protocolRequest.Protocol,
protocolRequest.Stream,
)
release, admissionErr := deps.admissions.acquire(
pinned.Plugin.Meta.Key,
common.GetContextKeyInt(c, constant.ContextKeyUserId),
common.GetContextKeyInt(c, constant.ContextKeyTokenId),
)
if admissionErr != nil {
if errors.Is(admissionErr, errPluginProtocolObservationLimitExceeded) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=admission_rejected generation=%d plugin=%q reason=observation_limit", generation, pluginKey)
respondPluginProtocolError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Too many active task observations")
return
}
logger.LogDebug(c, "task_plugin subsystem=protocol event=admission_rejected generation=%d plugin=%q reason=invalid_identity", generation, pluginKey)
respondPluginProtocolError(c, http.StatusUnauthorized, "authentication_error", "Authentication failed")
return
}
defer release()
logger.LogDebug(c, "task_plugin subsystem=protocol event=admission_acquired generation=%d plugin=%q", generation, pluginKey)
clientRequest := c.Request
var relayInfo *relaycommon.RelayInfo
var outcome *taskSubmissionOutcome
var taskErr *dto.TaskError
var relayInfoErr error
submissionStage := "relay_info"
// A Responses client only observes an asynchronous task. Once admitted,
// disconnecting that observer must not cancel submission, persistence, or
// billing settlement; the submission keeps its own bounded lifetime.
func() {
submissionContext, cancelSubmission := context.WithTimeout(
context.WithoutCancel(clientRequest.Context()),
deps.submissionTimeout,
)
c.Request = clientRequest.Clone(submissionContext)
defer func() {
c.Request = clientRequest
cancelSubmission()
}()
relayInfo, relayInfoErr = relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if relayInfoErr != nil {
return
}
relayInfo.RelayMode = relayconstant.RelayModeVideoSubmit
relayInfo.IsStream = false
relayInfo.OriginModelName = c.GetString("resolved_task_model")
if action := c.GetString("task_action"); action != "" {
relayInfo.Action = action
}
submissionStage = "origin_task"
if taskErr = relay.ResolveOriginTask(c, relayInfo); taskErr != nil {
return
}
if taskErr = relay.ApplyOriginTaskAffinity(c, relayInfo); taskErr != nil {
return
}
submissionStage = "submission"
logger.LogDebug(c, "task_plugin subsystem=protocol event=submission_start generation=%d plugin=%q protocol=%q stream=%t", generation, pluginKey, protocolRequest.Protocol, protocolRequest.Stream)
outcome, taskErr = deps.submit(c, relayInfo)
}()
if clientRequest.Context().Err() != nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q stage=%s", generation, pluginKey, submissionStage)
return
}
if relayInfoErr != nil {
err := relayInfoErr
logger.LogError(c, "build task protocol relay info failed: "+err.Error())
logger.LogDebug(c, "task_plugin subsystem=protocol event=bridge_failed generation=%d plugin=%q stage=relay_info reason=invalid_context", generation, pluginKey)
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
if submissionStage == "origin_task" && taskErr != nil {
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=bridge_failed generation=%d plugin=%q stage=origin_task code=%q status=%d",
generation,
pluginKey,
taskErr.Code,
taskErr.StatusCode,
)
respondPluginProtocolSubmissionError(c, taskErr)
return
}
if taskErr != nil {
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=submission_failed generation=%d plugin=%q code=%q status=%d local=%t",
generation,
pluginKey,
taskErr.Code,
taskErr.StatusCode,
taskErr.LocalError,
)
respondPluginProtocolSubmissionError(c, taskErr)
return
}
if outcome == nil || outcome.Task == nil || outcome.RelayInfo == nil ||
outcome.Task.UserId != relayInfo.UserId ||
outcome.Task.Platform != constant.TaskPlatform(pinned.Plugin.Meta.Key) {
logger.LogError(c, "task protocol submission returned an invalid durable outcome")
logger.LogDebug(c, "task_plugin subsystem=protocol event=submission_failed generation=%d plugin=%q reason=invalid_durable_outcome", generation, pluginKey)
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=submission_durable generation=%d plugin=%q public_task_id=%q status=%q stream=%t",
generation,
pluginKey,
outcome.Task.TaskID,
taskPluginDebugStatus(string(outcome.Task.Status)),
protocolRequest.Stream,
)
createdAt := outcome.Task.CreatedAt
if createdAt == 0 {
createdAt = outcome.Task.SubmitTime
}
if createdAt == 0 {
createdAt = deps.now().Unix()
}
machine := relay.NewPluginResponsesMachine(
outcome.Task.TaskID,
outcome.RelayInfo.OriginModelName,
createdAt,
deps.protocolLimits,
)
background := false
if body, ok := protocolRequest.Body.(map[string]any); ok && body["kind"] == string(pluginruntime.BodyJSON) {
if requestBody, ok := body["value"].(map[string]any); ok {
background, _ = requestBody["background"].(bool)
}
}
if background {
outcome.Task.PrivateData.ResponsesBackground = true
if outcome.Task.ID != 0 {
if err := model.DB.Model(outcome.Task).Update("private_data", outcome.Task.PrivateData).Error; err != nil {
logger.LogError(c, "persist task background flag failed: "+err.Error())
}
}
machine.SetBackground(true)
if !protocolRequest.Stream {
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=background_return generation=%d plugin=%q public_task_id=%q status=%q",
generation,
pluginKey,
outcome.Task.TaskID,
taskPluginDebugStatus(string(outcome.Task.Status)),
)
c.JSON(http.StatusOK, machine.PendingResponse(string(outcome.Task.Status)))
return
}
logger.LogDebug(c, "task_plugin subsystem=protocol event=background_stream generation=%d plugin=%q public_task_id=%q", generation, pluginKey, outcome.Task.TaskID)
}
if protocolRequest.Stream {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_enter generation=%d plugin=%q mode=stream public_task_id=%q", generation, pluginKey, outcome.Task.TaskID)
streamTaskPluginProtocol(c, pinned, protocolRequest, outcome.Task.TaskID, machine, deps)
return
}
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_enter generation=%d plugin=%q mode=nonstream public_task_id=%q", generation, pluginKey, outcome.Task.TaskID)
waitTaskPluginProtocol(c, pinned, protocolRequest, outcome.Task.TaskID, machine, deps)
}
func streamTaskPluginProtocol(
c *gin.Context,
pinned pluginruntime.PinnedEndpoint,
protocolRequest pluginruntime.ProtocolRequestContext,
taskID string,
machine *relay.PluginResponsesMachine,
deps pluginProtocolBridgeDeps,
) {
generation := pinned.Generation.Number
pluginKey := pinned.Plugin.Meta.Key
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_start generation=%d plugin=%q mode=stream public_task_id=%q timeout_ms=%d tick_ms=%d heartbeat_ms=%d",
generation,
pluginKey,
taskID,
deps.observationTimeout.Milliseconds(),
deps.tickInterval.Milliseconds(),
deps.heartbeatInterval.Milliseconds(),
)
created, err := machine.CreatedEvent()
if err != nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=created_event reason=state_machine_error", generation, pluginKey)
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
helper.SetEventStreamHeaders(c)
if err = writeTaskPluginProtocolEvent(c, created); err != nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_write_failed generation=%d plugin=%q mode=stream stage=created_event", generation, pluginKey)
return
}
observationContext, cancelObservation := context.WithTimeout(c.Request.Context(), deps.observationTimeout)
defer cancelObservation()
heartbeatTicker := time.NewTicker(deps.heartbeatInterval)
defer heartbeatTicker.Stop()
var previous relay.ProtocolState
tickNumber := uint64(0)
lastStatus := ""
for {
loadStarted := deps.now()
loadContext, cancelLoad := context.WithTimeout(observationContext, deps.loadTimeout)
task, exists, loadErr := deps.loadTask(
loadContext,
common.GetContextKeyInt(c, constant.ContextKeyUserId),
constant.TaskPlatform(pinned.Plugin.Meta.Key),
taskID,
)
loadContextErr := loadContext.Err()
cancelLoad()
loadElapsed := deps.now().Sub(loadStarted)
if errors.Is(loadContextErr, context.DeadlineExceeded) &&
observationContext.Err() == nil &&
c.Request.Context().Err() == nil {
logger.LogWarn(c, fmt.Sprintf(
"task protocol database observation overloaded; plugin=%s task=%s",
pinned.Plugin.Meta.Key,
taskID,
))
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_tick generation=%d plugin=%q mode=stream tick=%d load_ms=%d overloaded=true",
generation,
pluginKey,
tickNumber,
loadElapsed.Milliseconds(),
)
delay := pluginProtocolTickDelay(taskID, tickNumber, deps.tickInterval, deps.tickJitter) + deps.tickInterval
tickNumber++
if !waitForTaskPluginProtocolTick(c, observationContext, heartbeatTicker, delay) {
if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=stream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
writeTaskPluginProtocolTimeout(c, machine, lastStatus)
} else if c.Request.Context().Err() != nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=stream stage=backoff_wait", generation, pluginKey)
} else {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_write_failed generation=%d plugin=%q mode=stream stage=heartbeat", generation, pluginKey)
}
return
}
continue
}
if loadErr != nil || !exists || task == nil {
if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=stream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
writeTaskPluginProtocolTimeout(c, machine, lastStatus)
return
}
if loadErr != nil && !errors.Is(loadErr, context.Canceled) {
logger.LogError(c, "task protocol database observation failed")
}
if c.Request.Context().Err() == nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=load reason=task_unavailable", generation, pluginKey)
writeTaskPluginProtocolFailure(c, machine, lastStatus)
} else {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=stream stage=load", generation, pluginKey)
}
return
}
previousStatus := lastStatus
lastStatus = string(task.Status)
if lastStatus != previousStatus {
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=status_transition generation=%d plugin=%q mode=stream tick=%d previous=%q status=%q load_ms=%d",
generation,
pluginKey,
tickNumber,
taskPluginDebugStatus(previousStatus),
taskPluginDebugStatus(lastStatus),
loadElapsed.Milliseconds(),
)
}
view, viewErr := service.BuildTaskPluginView(task)
if viewErr != nil {
logger.LogError(c, "build task protocol view failed: "+viewErr.Error())
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
viewValue, viewErr := taskPluginProtocolJSONValue(view)
if viewErr != nil {
logger.LogError(c, "encode task protocol view failed: "+viewErr.Error())
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
hookStarted := deps.now()
rendererContext, contextErr := taskPluginProtocolRendererContext(protocolRequest, pinned, task, deps.artifactContentURL)
if contextErr != nil {
logger.LogError(c, "build task protocol renderer context failed")
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
args := []any{rendererContext, viewValue}
if previous.Present {
args = append(args, previous.PluginValue())
}
value, callErr := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(observationContext, deps.admissionTimeout, "protocols", []string{pinned.Protocol, "renderEvents"}, args...)
hookElapsed := deps.now().Sub(hookStarted)
overloaded := false
if callErr != nil {
if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=stream stage=render_events last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
writeTaskPluginProtocolTimeout(c, machine, lastStatus)
return
}
if c.Request.Context().Err() != nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=stream stage=render_events", generation, pluginKey)
return
}
if errors.Is(callErr, pluginruntime.ErrCallAdmissionTimeout) {
overloaded = true
logger.LogWarn(c, fmt.Sprintf(
"task protocol render hook overloaded; plugin=%s task=%s",
pinned.Plugin.Meta.Key,
taskID,
))
} else {
logger.LogError(c, "task protocol render hook failed")
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=stream stage=render_events reason=hook_failed elapsed_ms=%d",
generation,
pluginKey,
hookElapsed.Milliseconds(),
)
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
}
if !overloaded {
result, decodeErr := relay.DecodePluginProtocolEventResult(value, deps.protocolLimits)
if decodeErr != nil {
logger.LogError(c, "task protocol render result invalid: "+decodeErr.Error())
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
events, applyErr := machine.ApplyTick(result, lastStatus)
if applyErr != nil {
logger.LogError(c, "task protocol state transition failed: "+applyErr.Error())
writeTaskPluginProtocolFailure(c, machine, lastStatus)
return
}
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=render_events generation=%d plugin=%q mode=stream tick=%d status=%q semantic_events=%d wire_events=%d done=%t state_present=%t elapsed_ms=%d",
generation,
pluginKey,
tickNumber,
taskPluginDebugStatus(lastStatus),
len(result.Events),
len(events),
result.Done,
result.State.Present,
hookElapsed.Milliseconds(),
)
for _, event := range events {
if err = writeTaskPluginProtocolEvent(c, event); err != nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_write_failed generation=%d plugin=%q mode=stream stage=event event_type=%q sequence=%d", generation, pluginKey, event.Type, event.SequenceNumber)
return
}
}
if taskPluginProtocolEventsTerminal(events) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_complete generation=%d plugin=%q mode=stream reason=terminal status=%q ticks=%d", generation, pluginKey, taskPluginDebugStatus(lastStatus), tickNumber+1)
return
}
previous = result.State
}
delay := pluginProtocolTickDelay(taskID, tickNumber, deps.tickInterval, deps.tickJitter)
tickNumber++
if overloaded {
delay += deps.tickInterval
} else if hookElapsed > deps.tickInterval {
delay += deps.tickInterval
logger.LogWarn(c, fmt.Sprintf(
"task protocol render hook slow; plugin=%s task=%s elapsed_ms=%d",
pinned.Plugin.Meta.Key,
taskID,
hookElapsed.Milliseconds(),
))
}
if !waitForTaskPluginProtocolTick(c, observationContext, heartbeatTicker, delay) {
if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=stream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
writeTaskPluginProtocolTimeout(c, machine, lastStatus)
} else if c.Request.Context().Err() != nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=stream stage=tick_wait", generation, pluginKey)
} else {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_write_failed generation=%d plugin=%q mode=stream stage=heartbeat", generation, pluginKey)
}
return
}
}
}
func waitForTaskPluginProtocolTick(
c *gin.Context,
observationContext context.Context,
heartbeatTicker *time.Ticker,
delay time.Duration,
) bool {
tickTimer := time.NewTimer(delay)
defer tickTimer.Stop()
for {
select {
case <-c.Request.Context().Done():
return false
case <-observationContext.Done():
return false
case <-heartbeatTicker.C:
helper.ExtendWriteDeadline(c)
if err := writeTaskPluginProtocolHeartbeat(c); err != nil {
return false
}
case <-tickTimer.C:
return true
}
}
}
func waitTaskPluginProtocol(
c *gin.Context,
pinned pluginruntime.PinnedEndpoint,
protocolRequest pluginruntime.ProtocolRequestContext,
taskID string,
machine *relay.PluginResponsesMachine,
deps pluginProtocolBridgeDeps,
) {
generation := pinned.Generation.Number
pluginKey := pinned.Plugin.Meta.Key
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_start generation=%d plugin=%q mode=nonstream public_task_id=%q timeout_ms=%d tick_ms=%d",
generation,
pluginKey,
taskID,
deps.observationTimeout.Milliseconds(),
deps.tickInterval.Milliseconds(),
)
observationContext, cancelObservation := context.WithTimeout(c.Request.Context(), deps.observationTimeout)
defer cancelObservation()
tickNumber := uint64(0)
lastStatus := ""
for {
loadStarted := deps.now()
loadContext, cancelLoad := context.WithTimeout(observationContext, deps.loadTimeout)
task, exists, err := deps.loadTask(
loadContext,
common.GetContextKeyInt(c, constant.ContextKeyUserId),
constant.TaskPlatform(pinned.Plugin.Meta.Key),
taskID,
)
loadContextErr := loadContext.Err()
cancelLoad()
loadElapsed := deps.now().Sub(loadStarted)
loadOverloaded := errors.Is(loadContextErr, context.DeadlineExceeded) &&
observationContext.Err() == nil &&
c.Request.Context().Err() == nil
if loadOverloaded {
logger.LogWarn(c, fmt.Sprintf(
"task protocol database observation overloaded; plugin=%s task=%s",
pinned.Plugin.Meta.Key,
taskID,
))
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_tick generation=%d plugin=%q mode=nonstream tick=%d load_ms=%d overloaded=true",
generation,
pluginKey,
tickNumber,
loadElapsed.Milliseconds(),
)
} else if err != nil || !exists || task == nil {
if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=nonstream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
writeTaskPluginProtocolTimeoutResponse(c, machine, lastStatus)
return
}
if err != nil && !errors.Is(err, context.Canceled) {
logger.LogError(c, "task protocol database observation failed")
}
if c.Request.Context().Err() == nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=nonstream stage=load reason=task_unavailable", generation, pluginKey)
writeTaskPluginProtocolFailureResponse(c, machine, lastStatus)
} else {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=nonstream stage=load", generation, pluginKey)
}
return
}
overloaded := loadOverloaded
if !loadOverloaded {
previousStatus := lastStatus
lastStatus = string(task.Status)
if lastStatus != previousStatus {
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=status_transition generation=%d plugin=%q mode=nonstream tick=%d previous=%q status=%q load_ms=%d",
generation,
pluginKey,
tickNumber,
taskPluginDebugStatus(previousStatus),
taskPluginDebugStatus(lastStatus),
loadElapsed.Milliseconds(),
)
}
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_tick generation=%d plugin=%q mode=nonstream tick=%d status=%q load_ms=%d overloaded=false",
generation,
pluginKey,
tickNumber,
taskPluginDebugStatus(lastStatus),
loadElapsed.Milliseconds(),
)
}
if !loadOverloaded && (task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure) {
if task.Status == model.TaskStatusFailure {
writeTaskPluginProtocolFailureResponse(c, machine, string(task.Status))
return
}
response, hookElapsed, callErr := renderTaskPluginProtocolFinalResponse(
observationContext,
pinned,
protocolRequest,
task,
machine,
deps,
)
if callErr != nil {
if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=nonstream stage=render_final last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
writeTaskPluginProtocolTimeoutResponse(c, machine, lastStatus)
return
}
if c.Request.Context().Err() != nil {
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=nonstream stage=render_final", generation, pluginKey)
return
}
if errors.Is(callErr, pluginruntime.ErrCallAdmissionTimeout) {
overloaded = true
logger.LogWarn(c, fmt.Sprintf(
"task protocol final hook overloaded; plugin=%s task=%s",
pinned.Plugin.Meta.Key,
taskID,
))
} else {
logger.LogError(c, "task protocol final hook failed")
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_failed generation=%d plugin=%q mode=nonstream stage=render_final reason=hook_failed elapsed_ms=%d",
generation,
pluginKey,
hookElapsed.Milliseconds(),
)
writeTaskPluginProtocolFailureResponse(c, machine, lastStatus)
return
}
} else {
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=render_final generation=%d plugin=%q mode=nonstream status=%q elapsed_ms=%d",
generation,
pluginKey,
taskPluginDebugStatus(lastStatus),
hookElapsed.Milliseconds(),
)
c.JSON(http.StatusOK, response)
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=observation_complete generation=%d plugin=%q mode=nonstream reason=terminal status=%q ticks=%d",
generation,
pluginKey,
taskPluginDebugStatus(lastStatus),
tickNumber+1,
)
return
}
}
delay := pluginProtocolTickDelay(taskID, tickNumber, deps.tickInterval, deps.tickJitter)
tickNumber++
if overloaded {
delay += deps.tickInterval
}
tickTimer := time.NewTimer(delay)
select {
case <-c.Request.Context().Done():
if !tickTimer.Stop() {
select {
case <-tickTimer.C:
default:
}
}
logger.LogDebug(c, "task_plugin subsystem=protocol event=client_disconnected generation=%d plugin=%q mode=nonstream stage=tick_wait", generation, pluginKey)
return
case <-observationContext.Done():
if !tickTimer.Stop() {
select {
case <-tickTimer.C:
default:
}
}
if errors.Is(observationContext.Err(), context.DeadlineExceeded) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=observation_timeout generation=%d plugin=%q mode=nonstream last_status=%q", generation, pluginKey, taskPluginDebugStatus(lastStatus))
writeTaskPluginProtocolTimeoutResponse(c, machine, lastStatus)
}
return
case <-tickTimer.C:
}
}
}
func renderTaskPluginProtocolFinalResponse(
ctx context.Context,
pinned pluginruntime.PinnedEndpoint,
protocolRequest pluginruntime.ProtocolRequestContext,
task *model.Task,
machine *relay.PluginResponsesMachine,
deps pluginProtocolBridgeDeps,
) (map[string]any, time.Duration, error) {
view, err := service.BuildTaskPluginView(task)
if err != nil {
return nil, 0, err
}
viewValue, err := taskPluginProtocolJSONValue(view)
if err != nil {
return nil, 0, err
}
rendererContext, err := taskPluginProtocolRendererContext(
protocolRequest,
pinned,
task,
deps.artifactContentURL,
)
if err != nil {
return nil, 0, err
}
hookStarted := deps.now()
payload, err := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(
ctx,
deps.admissionTimeout,
"protocols",
[]string{pinned.Protocol, "renderFinal"},
rendererContext,
viewValue,
)
hookElapsed := deps.now().Sub(hookStarted)
if err != nil {
return nil, hookElapsed, err
}
response, err := machine.FinalResponse(payload, string(task.Status))
if err != nil {
return nil, hookElapsed, err
}
return response, hookElapsed, nil
}
func renderTaskPluginProtocolEventsResponse(
ctx context.Context,
pinned pluginruntime.PinnedEndpoint,
protocolRequest pluginruntime.ProtocolRequestContext,
task *model.Task,
machine *relay.PluginResponsesMachine,
deps pluginProtocolBridgeDeps,
) (map[string]any, time.Duration, error) {
view, err := service.BuildTaskPluginView(task)
if err != nil {
return nil, 0, err
}
viewValue, err := taskPluginProtocolJSONValue(view)
if err != nil {
return nil, 0, err
}
rendererContext, err := taskPluginProtocolRendererContext(
protocolRequest,
pinned,
task,
deps.artifactContentURL,
)
if err != nil {
return nil, 0, err
}
hookStarted := deps.now()
value, err := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(
ctx,
deps.admissionTimeout,
"protocols",
[]string{pinned.Protocol, "renderEvents"},
rendererContext,
viewValue,
)
hookElapsed := deps.now().Sub(hookStarted)
if err != nil {
return nil, hookElapsed, err
}
result, err := relay.DecodePluginProtocolEventResult(value, deps.protocolLimits)
if err != nil {
return nil, hookElapsed, err
}
response, err := machine.FinalFromEvents(result, string(task.Status))
if err != nil {
return nil, hookElapsed, err
}
return response, hookElapsed, nil
}
func RetrieveTaskPluginResponse(c *gin.Context) {
retrieveTaskPluginResponse(c, defaultPluginProtocolBridgeDeps())
}
func retrieveTaskPluginResponse(c *gin.Context, deps pluginProtocolBridgeDeps) {
deps = deps.withDefaults()
responseID := strings.TrimSpace(c.Param("response_id"))
if !strings.HasPrefix(responseID, "resp_") {
writeTaskPluginResponseNotFound(c, responseID, "bad_prefix")
return
}
taskID := "task_" + strings.TrimPrefix(responseID, "resp_")
userID := common.GetContextKeyInt(c, constant.ContextKeyUserId)
logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_start response_id=%q public_task_id=%q", responseID, taskID)
task, exists, err := deps.getByTaskId(userID, taskID)
if err != nil {
logger.LogError(c, "task protocol retrieve lookup failed")
logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_failed reason=lookup_error public_task_id=%q", taskID)
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
if !exists || task == nil {
writeTaskPluginResponseNotFound(c, responseID, "missing")
return
}
if !task.ResultRetrievable() {
writeTaskPluginResponseNotFound(c, responseID, "result_discarded")
return
}
plugin, generation, ok := deps.resolvePlugin(task.Platform)
if !ok || plugin == nil {
writeTaskPluginResponseNotFound(c, responseID, "no_plugin")
return
}
claimsProtocol := false
for _, claim := range plugin.Meta.Protocols {
if claim.Name == "openai_responses" {
claimsProtocol = true
break
}
}
if !claimsProtocol {
writeTaskPluginResponseNotFound(c, responseID, "no_claim")
return
}
generationNumber := uint64(0)
if generation != nil {
generationNumber = generation.Number
}
createdAt := task.CreatedAt
if createdAt == 0 {
createdAt = task.SubmitTime
}
if createdAt == 0 {
createdAt = deps.now().Unix()
}
machine := relay.NewPluginResponsesMachine(
task.TaskID,
task.Properties.OriginModelName,
createdAt,
deps.protocolLimits,
)
machine.SetBackground(task.PrivateData.ResponsesBackground)
pinned := pluginruntime.PinnedEndpoint{
Generation: generation,
Plugin: plugin,
Protocol: "openai_responses",
Model: task.Properties.OriginModelName,
}
protocolRequest := pluginruntime.ProtocolRequestContext{
RouteRequestContext: pluginruntime.RouteRequestContext{
Path: c.Request.URL.Path,
Method: http.MethodGet,
Params: map[string]string{"response_id": responseID},
Query: c.Request.URL.Query(),
Body: map[string]any{"kind": string(pluginruntime.BodyNone)},
},
Protocol: "openai_responses",
Operation: "retrieve",
Model: task.Properties.OriginModelName,
}
if task.Status == model.TaskStatusFailure {
logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_final generation=%d plugin=%q public_task_id=%q status=%q", generationNumber, plugin.Meta.Key, task.TaskID, taskPluginDebugStatus(string(task.Status)))
writeTaskPluginProtocolFailureResponse(c, machine, string(task.Status))
return
}
if task.Status != model.TaskStatusSuccess {
logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_pending generation=%d plugin=%q public_task_id=%q status=%q", generationNumber, plugin.Meta.Key, task.TaskID, taskPluginDebugStatus(string(task.Status)))
c.JSON(http.StatusOK, machine.PendingResponse(string(task.Status)))
return
}
var (
response map[string]any
hookElapsed time.Duration
renderErr error
)
if plugin.Meta.ProtocolSupports("openai_responses", "sync") || plugin.Meta.ProtocolSupports("openai_responses", "background") {
response, hookElapsed, renderErr = renderTaskPluginProtocolFinalResponse(
c.Request.Context(),
pinned,
protocolRequest,
task,
machine,
deps,
)
} else {
response, hookElapsed, renderErr = renderTaskPluginProtocolEventsResponse(
c.Request.Context(),
pinned,
protocolRequest,
task,
machine,
deps,
)
}
if renderErr != nil {
logger.LogError(c, "task protocol retrieve render failed")
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=retrieve_failed generation=%d plugin=%q public_task_id=%q stage=render_final elapsed_ms=%d",
generationNumber,
plugin.Meta.Key,
task.TaskID,
hookElapsed.Milliseconds(),
)
writeTaskPluginProtocolFailureResponse(c, machine, string(task.Status))
return
}
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=retrieve_final generation=%d plugin=%q public_task_id=%q status=%q elapsed_ms=%d",
generationNumber,
plugin.Meta.Key,
task.TaskID,
taskPluginDebugStatus(string(task.Status)),
hookElapsed.Milliseconds(),
)
c.JSON(http.StatusOK, response)
}
func writeTaskPluginResponseNotFound(c *gin.Context, responseID, reason string) {
logger.LogDebug(c, "task_plugin subsystem=protocol event=retrieve_not_found reason=%s response_id=%q", reason, responseID)
respondPluginProtocolError(c, http.StatusNotFound, "not_found", "No response found with id '"+responseID+"'.")
}
func writeTaskPluginProtocolHeartbeat(c *gin.Context) error {
if _, err := c.Writer.Write([]byte(": PING\n")); err != nil {
return err
}
return helper.FlushWriter(c)
}
func writeTaskPluginProtocolFailure(
c *gin.Context,
machine *relay.PluginResponsesMachine,
taskStatus string,
) {
failed, err := machine.FailureEvent(taskStatus)
if err != nil {
logger.LogError(c, "task protocol failure event failed: "+err.Error())
return
}
_ = writeTaskPluginProtocolEvent(c, failed)
}
func writeTaskPluginProtocolTimeout(
c *gin.Context,
machine *relay.PluginResponsesMachine,
taskStatus string,
) {
incomplete, err := machine.TimeoutEvent(taskStatus)
if err != nil {
logger.LogError(c, "task protocol timeout event failed: "+err.Error())
return
}
_ = writeTaskPluginProtocolEvent(c, incomplete)
}
func writeTaskPluginProtocolFailureResponse(
c *gin.Context,
machine *relay.PluginResponsesMachine,
taskStatus string,
) {
if taskStatus == string(model.TaskStatusFailure) {
response, err := machine.FinalResponse(nil, taskStatus)
if err != nil {
logger.LogError(c, "task protocol terminal failure response failed: "+err.Error())
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
c.JSON(http.StatusOK, response)
return
}
response, err := machine.FailureResponse(taskStatus)
if err != nil {
logger.LogError(c, "task protocol failure response failed: "+err.Error())
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
c.JSON(http.StatusOK, response)
}
func writeTaskPluginProtocolTimeoutResponse(
c *gin.Context,
machine *relay.PluginResponsesMachine,
lastStatus string,
) {
response, err := machine.TimeoutResponse(lastStatus)
if err != nil {
logger.LogError(c, "task protocol timeout response failed: "+err.Error())
respondPluginProtocolError(c, http.StatusInternalServerError, "task_protocol_error", "Task protocol request failed")
return
}
c.JSON(http.StatusOK, response)
}
// taskPluginProtocolJSONValue encodes a task view for a hook, which receives
// it parsed from the text.
func taskPluginProtocolJSONValue(view dto.TaskView) (any, error) {
encoded, err := common.Marshal(view)
if err != nil {
return nil, err
}
return pluginruntime.RawJSON(encoded), nil
}
func taskPluginProtocolRendererContext(
request pluginruntime.ProtocolRequestContext,
pinned pluginruntime.PinnedEndpoint,
task *model.Task,
artifactContentURL func(taskID, artifactKey string) (string, error),
) (map[string]any, error) {
rendererContext := request.JSValue()
if task == nil || task.Status != model.TaskStatusSuccess {
return rendererContext, nil
}
if pinned.Plugin == nil {
return nil, errors.New("task artifact projection is unavailable")
}
artifacts, err := taskjsplugin.New(pinned.Plugin).ListArtifacts(task)
if err != nil {
return nil, fmt.Errorf("project task artifacts: %w", err)
}
artifacts, err = validateProjectedTaskArtifacts(artifacts)
if err != nil {
return nil, err
}
if len(artifacts) > 0 && artifactContentURL == nil {
return nil, errors.New("task artifact projection is unavailable")
}
rendererArtifacts := make(map[string]any, len(artifacts))
for _, artifact := range artifacts {
contentURL, buildErr := artifactContentURL(task.TaskID, artifact.Key)
if buildErr != nil {
return nil, fmt.Errorf("build task artifact content URL: %w", buildErr)
}
item := map[string]any{
"key": artifact.Key,
"type": artifact.Type,
"url": contentURL,
}
if artifact.MimeType != "" {
item["mimeType"] = artifact.MimeType
}
rendererArtifacts[artifact.Key] = item
}
rendererContext["artifacts"] = rendererArtifacts
return rendererContext, nil
}
func pluginProtocolTickDelay(taskID string, tick uint64, base, jitter time.Duration) time.Duration {
if jitter <= 0 {
return base
}
hash := fnv.New64a()
_, _ = hash.Write([]byte(taskID))
_, _ = hash.Write([]byte(":"))
_, _ = hash.Write([]byte(strconv.FormatUint(tick, 10)))
return base + time.Duration(hash.Sum64()%uint64(jitter+1))
}
func taskPluginProtocolEventsTerminal(events []dto.PluginResponsesStreamEvent) bool {
for _, event := range events {
switch event.Type {
case "response.completed", "response.failed", "response.incomplete":
return true
}
}
return false
}
func writeTaskPluginProtocolEvent(c *gin.Context, event dto.PluginResponsesStreamEvent) error {
encoded, err := common.Marshal(event)
if err != nil {
return err
}
helper.ExtendWriteDeadline(c)
if _, err = c.Writer.Write([]byte("event: " + event.Type + "\n")); err != nil {
return err
}
if _, err = c.Writer.Write([]byte("data: " + string(encoded) + "\n\n")); err != nil {
return err
}
if err = helper.FlushWriter(c); err != nil {
return err
}
logger.LogDebug(
c,
"task_plugin subsystem=protocol event=sse_event_sent event_type=%q sequence=%d",
event.Type,
event.SequenceNumber,
)
return nil
}
func respondPluginProtocolSubmissionError(c *gin.Context, taskErr *dto.TaskError) {
status := http.StatusInternalServerError
if taskErr != nil && taskErr.StatusCode >= 400 && taskErr.StatusCode <= 599 {
status = taskErr.StatusCode
}
switch status {
case http.StatusBadRequest:
message := "Invalid task protocol request"
if taskErr != nil && taskErr.Message != "" && (taskErr.Code == "invalid_request" || strings.HasPrefix(taskErr.Code, "invalid_request")) {
message = taskErr.Message
}
respondPluginProtocolError(c, status, "invalid_request_error", message)
case http.StatusUnauthorized:
respondPluginProtocolError(c, status, "authentication_error", "Authentication failed")
case http.StatusForbidden:
respondPluginProtocolError(c, status, "permission_denied", "Task protocol request was denied")
case http.StatusTooManyRequests:
respondPluginProtocolError(c, status, "rate_limit_exceeded", "Too many requests")
default:
respondPluginProtocolError(c, status, "task_protocol_error", "Task protocol request failed")
}
}
func respondPluginProtocolError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{
"error": gin.H{
"message": message,
"type": "new_api_error",
"code": code,
},
})
}