Files
new-api-custom/relay/channel/task/jsplugin/adaptor.go
T

1955 lines
68 KiB
Go

package jsplugin
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"math"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
type requestDescriptor struct {
ResponseType string `json:"responseType"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body any `json:"body"`
Credentialless bool `json:"credentialless"`
Action string `json:"action"`
Model string `json:"model"`
RewriteModel string `json:"rewriteModel"`
BodyType string `json:"bodyType"`
Parts []requestPart `json:"parts"`
// BodyText is the body as JSON.stringify wrote it, for a plugin that
// preserves JSON order: the upstream gets the members in that order.
BodyText json.RawMessage `json:"-"`
}
// orderedRequestDescriptor is requestDescriptor as a plugin that preserves
// JSON order returns it: decoded from the JSON.stringify text of the result,
// with the body kept as that text.
type orderedRequestDescriptor struct {
ResponseType string `json:"responseType"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body json.RawMessage `json:"body"`
Credentialless bool `json:"credentialless"`
Action string `json:"action"`
Model string `json:"model"`
RewriteModel string `json:"rewriteModel"`
BodyType string `json:"bodyType"`
Parts []requestPart `json:"parts"`
}
type requestPart struct {
Name string `json:"name"`
Value any `json:"value"`
FileRef string `json:"fileRef"`
Filename string `json:"filename"`
}
type submitResponse struct {
TaskID string `json:"taskId"`
TaskData any `json:"taskData"`
Immediate *taskResult `json:"immediate"`
State any `json:"state"`
}
type taskResult struct {
Code int `json:"code"`
TaskID string `json:"taskId"`
Status string `json:"status"`
Progress string `json:"progress"`
Reason string `json:"reason"`
URL string `json:"url"`
RemoteURL string `json:"remoteUrl"`
CompletionTokens float64 `json:"completionTokens"`
TotalTokens float64 `json:"totalTokens"`
State any `json:"state"`
}
var taskArtifactKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
const maxTaskArtifacts = 64
// maxTaskPluginPersistedJSONBytes is the shared ceiling for taskData and plugin state.
const maxTaskPluginPersistedJSONBytes = 1 << 20
type TaskAdaptor struct {
plugin *pluginruntime.LoadedPlugin
info *relaycommon.RelayInfo
submit *requestDescriptor
routeRequest *pluginruntime.RouteRequestContext
requestHeaders map[string]string
files []map[string]any
// hookRequestBody is hookRequestSource as hooks receive it.
hookRequestSource map[string]any
hookRequestBody any
// requestBodyText is the decoded requestBody as JSON text, which hooks
// of a plugin that preserves JSON order receive instead.
requestBodyText json.RawMessage
}
func New(plugin *pluginruntime.LoadedPlugin) *TaskAdaptor { return &TaskAdaptor{plugin: plugin} }
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.info = info }
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint); exists {
if pinned, ok := pinnedValue.(pluginruntime.PinnedEndpoint); ok && pinned.Plugin == a.plugin {
if protocolValue, present := c.Get(pluginruntime.ContextKeyProtocolRequest); present {
if protocolContext, valid := protocolValue.(pluginruntime.ProtocolRequestContext); valid {
resolvedValue, requestBodyText, callErr := a.plugin.Engine.CallPathWithMemberJSON(
context.WithoutCancel(c.Request.Context()), 0, a.plugin.Meta.JSONTextMember("requestBody"),
"protocols", []string{pinned.Protocol, "decodeRequest"}, protocolContext.JSValueFor(a.plugin.Meta),
)
resolved, resolvedOK := resolvedValue.(map[string]any)
resolvedModel, modelOK := resolved["model"].(string)
if callErr != nil || !resolvedOK || !modelOK || resolvedModel != pinned.Model {
return service.TaskErrorWrapperLocal(fmt.Errorf("final task plugin decoder rejected the pinned model"), "plugin_request_invalid", http.StatusBadRequest)
}
if _, forbidden := resolved["renderer"]; forbidden {
return service.TaskErrorWrapperLocal(fmt.Errorf("decoder must not return renderer"), "plugin_request_invalid", http.StatusBadRequest)
}
if body, present := resolved["requestBody"]; present {
c.Set("task_request", body)
}
if requestBodyText != nil {
c.Set(pluginruntime.ContextKeyRequestBodyText, requestBodyText)
}
if action, valid := resolved["action"].(string); valid && strings.TrimSpace(action) != "" {
c.Set("task_action", action)
info.Action = action
}
}
}
}
}
if _, exists := c.Get("task_request"); !exists {
if taskErr := relaycommon.ValidateBasicTaskRequest(c, info, "image_to_video"); taskErr != nil {
return taskErr
}
}
request, hasRequest := c.Get("task_request")
hasUsageProfiles := len(a.plugin.Meta.UsageProfiles) > 0
if hasRequest && !hasUsageProfiles {
if err := a.validateResolvedUsageRequest(request); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
}
}
if _, err := a.buildSubmit(c, info); err != nil {
// utils.fetch failures in the build hook name the host fetched: the
// channel base URL or one of the plugin's allowed hosts, which an
// installed plugin may point at private hosts.
taskErr := service.TaskErrorWrapper(err, "plugin_request_invalid", http.StatusBadRequest, append(common.UpstreamAddresses(c), a.plugin.Meta.AllowedHosts...)...)
taskErr.LocalError = true
return taskErr
}
// The descriptor may rewrite the model. Validate profiled requests against
// that final model before any quota calculation or upstream submission; an
// upstream endpoint ID without a profile keeps the client model's profile.
if hasRequest && hasUsageProfiles {
if err := a.validateResolvedUsageRequest(request, info.UpstreamModelName, info.OriginModelName); err != nil {
return service.TaskErrorWrapperLocal(err, "plugin_usage_invalid", http.StatusBadRequest)
}
}
return nil
}
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
ratios, err := a.EstimateBillingValidated(c, info)
if err != nil {
a.logRejectedUsage("extractUsage", err)
return nil
}
return ratios
}
func (a *TaskAdaptor) EstimateBillingValidated(c *gin.Context, info *relaycommon.RelayInfo) (map[string]float64, error) {
usageContext := a.submitContext(c, info)
usageContext["usagePurpose"] = "billing_ratios"
return a.usageRatios(c.Request.Context(), []string{info.UpstreamModelName, info.OriginModelName}, "extractUsage", usageContext)
}
func (a *TaskAdaptor) ExtractUsageFacts(c *gin.Context, info *relaycommon.RelayInfo) map[string]any {
facts, err := a.ExtractUsageFactsValidated(c, info)
if err != nil {
a.logRejectedUsage("extractUsage", err)
return nil
}
return facts
}
func (a *TaskAdaptor) ExtractUsageFactsValidated(c *gin.Context, info *relaycommon.RelayInfo) (map[string]any, error) {
if !a.hasHook(c.Request.Context(), "extractUsage") {
return nil, nil
}
usageContext := a.submitContext(c, info)
usageContext["usagePurpose"] = "facts"
value, err := a.plugin.Engine.Call(c.Request.Context(), "extractUsage", usageContext)
if err != nil {
return nil, fmt.Errorf("plugin usage hook failed")
}
if value == nil {
return nil, nil
}
facts, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("plugin usage hook must return an object")
}
validated, _, err := a.validatedUsageRatios(facts, info.UpstreamModelName, info.OriginModelName)
if err != nil {
return nil, err
}
return validated, nil
}
func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 {
var data any
if err := common.Unmarshal(taskData, &data); err != nil {
data = string(taskData)
}
ratios, err := a.usageRatios(context.Background(), []string{info.UpstreamModelName, info.OriginModelName}, "extractUsageOnSubmit", a.submitContext(nil, info), data)
if err != nil {
a.logRejectedUsage("extractUsageOnSubmit", err)
return nil
}
return ratios
}
func (a *TaskAdaptor) AdjustBillingOnComplete(task *model.Task, result *relaycommon.TaskInfo) int {
if !a.hasHook(context.Background(), "extractUsageOnComplete") {
return 0
}
value, err := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", jsonValue(task), jsonValue(result))
if err != nil {
return 0
}
if task == nil {
a.applyCompletionUsageFacts(result, value)
return 0
}
a.applyCompletionUsageFacts(result, value, task.Properties.UpstreamModelName, task.Properties.OriginModelName)
return 0
}
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
if a.submit == nil {
return "", fmt.Errorf("plugin submit request was not built")
}
return a.submit.URL, pluginruntime.ValidateRequestURL(a.submit.URL, info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts)
}
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
if a.submit == nil {
return fmt.Errorf("plugin submit request was not built")
}
for name, value := range a.submit.Headers {
req.Header.Set(name, value)
}
// BuildRequestBody rebuilt the multipart body with a new boundary and put its
// Content-Type on the client request; a plugin's own Content-Type cannot match it.
if a.submit.BodyType == "multipart" {
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
}
return nil
}
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
descriptor, err := a.buildSubmit(c, info)
if err != nil {
return nil, err
}
if descriptor.BodyType == "multipart" {
form, parseErr := common.ParseMultipartFormReusable(c)
if parseErr != nil {
return nil, parseErr
}
defer form.RemoveAll()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, part := range descriptor.Parts {
if part.FileRef == "" {
header := make(textproto.MIMEHeader)
disposition := mime.FormatMediaType("form-data", map[string]string{"name": part.Name})
if disposition == "" {
return nil, fmt.Errorf("invalid multipart name")
}
header.Set("Content-Disposition", disposition)
destination, createErr := writer.CreatePart(header)
if createErr != nil {
return nil, createErr
}
if _, err = io.WriteString(destination, fmt.Sprint(part.Value)); err != nil {
return nil, err
}
continue
}
field, index, validRef := pluginruntime.ParseFileReference(part.FileRef)
files := form.File[field]
if !validRef || index >= len(files) {
return nil, fmt.Errorf("unknown file reference %q", part.FileRef)
}
file, openErr := files[index].Open()
if openErr != nil {
return nil, openErr
}
filename := part.Filename
if filename == "" {
filename = files[index].Filename
}
header := make(textproto.MIMEHeader)
disposition := mime.FormatMediaType("form-data", map[string]string{"name": part.Name, "filename": filename})
if disposition == "" {
file.Close()
return nil, fmt.Errorf("invalid multipart name or filename")
}
header.Set("Content-Disposition", disposition)
header.Set("Content-Type", files[index].Header.Get("Content-Type"))
destination, copyErr := writer.CreatePart(header)
if copyErr == nil {
_, copyErr = io.Copy(destination, file)
}
file.Close()
if copyErr != nil {
return nil, copyErr
}
}
if err = writer.Close(); err != nil {
return nil, err
}
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
return bytes.NewReader(body.Bytes()), nil
}
if descriptor.Body == nil {
return nil, nil
}
if text, ok := descriptor.Body.(string); ok {
return strings.NewReader(text), nil
}
value := descriptor.Body
if descriptor.BodyText != nil {
if !bytes.Contains(descriptor.BodyText, []byte(`"__fileRef":`)) {
return bytes.NewReader(descriptor.BodyText), nil
}
// File placeholders resolve only in the decoded value, which writes
// the members sorted; uploads carry no client JSON order to keep.
if err := common.Unmarshal(descriptor.BodyText, &value); err != nil {
return nil, err
}
}
inlined, err := inlineJSONFilePlaceholders(c, value)
if err != nil {
return nil, err
}
body, err := common.Marshal(inlined)
if err != nil {
return nil, err
}
return bytes.NewReader(body), nil
}
func maxInlineFileBytes() int64 {
limitMB := constant.MaxFileDownloadMB
if limitMB <= 0 {
limitMB = 64
}
return int64(limitMB) << 20
}
func inlineJSONFilePlaceholders(c *gin.Context, body any) (any, error) {
cloned := jsonValue(body)
var form *multipart.Form
if c != nil && c.Request != nil && strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
parsed, parseErr := common.ParseMultipartFormReusable(c)
if parseErr != nil {
return nil, parseErr
}
form = parsed
defer form.RemoveAll()
}
limit := maxInlineFileBytes()
var total int64
return replaceJSONFilePlaceholders(cloned, form, limit, &total)
}
func replaceJSONFilePlaceholders(value any, form *multipart.Form, limit int64, total *int64) (any, error) {
switch typed := value.(type) {
case map[string]any:
if _, isPlaceholder := typed["__fileRef"]; isPlaceholder {
return encodeFilePlaceholder(typed, form, limit, total)
}
for key, item := range typed {
replaced, err := replaceJSONFilePlaceholders(item, form, limit, total)
if err != nil {
return nil, err
}
typed[key] = replaced
}
return typed, nil
case []any:
for index, item := range typed {
replaced, err := replaceJSONFilePlaceholders(item, form, limit, total)
if err != nil {
return nil, err
}
typed[index] = replaced
}
return typed, nil
default:
return value, nil
}
}
func encodeFilePlaceholder(placeholder map[string]any, form *multipart.Form, limit int64, total *int64) (string, error) {
for key := range placeholder {
switch key {
case "__fileRef", "encoding", "mimeType", "maxBytes":
default:
return "", fmt.Errorf("invalid file placeholder")
}
}
ref, _ := placeholder["__fileRef"].(string)
if strings.TrimSpace(ref) == "" {
return "", fmt.Errorf("unknown file reference %q", ref)
}
encoding, _ := placeholder["encoding"].(string)
if encoding != "base64" && encoding != "dataUrl" {
return "", fmt.Errorf("file placeholder encoding must be \"base64\" or \"dataUrl\"")
}
if form == nil {
return "", fmt.Errorf("unknown file reference %q", ref)
}
field, index, validRef := pluginruntime.ParseFileReference(ref)
files := form.File[field]
if !validRef || index >= len(files) {
return "", fmt.Errorf("unknown file reference %q", ref)
}
header := files[index]
maxBytes := limit
if raw, exists := placeholder["maxBytes"]; exists {
n, ok := usageNumber(raw, false)
if !ok || n <= 0 || n != math.Trunc(n) {
return "", fmt.Errorf("invalid file placeholder")
}
if int64(n) < maxBytes {
maxBytes = int64(n)
}
}
if header.Size > maxBytes {
return "", fmt.Errorf("file %q exceeds the %d byte limit", ref, maxBytes)
}
file, openErr := header.Open()
if openErr != nil {
return "", openErr
}
data, readErr := io.ReadAll(io.LimitReader(file, maxBytes+1))
file.Close()
if readErr != nil {
return "", readErr
}
if int64(len(data)) > maxBytes {
return "", fmt.Errorf("file %q exceeds the %d byte limit", ref, maxBytes)
}
if *total+int64(len(data)) > limit {
return "", fmt.Errorf("inlined files exceed the %d byte limit", limit)
}
*total += int64(len(data))
encoded := base64.StdEncoding.EncodeToString(data)
if encoding == "base64" {
return encoded, nil
}
mimeType := "application/octet-stream"
if override, ok := placeholder["mimeType"].(string); ok && strings.TrimSpace(override) != "" {
mimeType = override
} else if contentType := header.Header.Get("Content-Type"); contentType != "" {
mimeType = contentType
}
return "data:" + mimeType + ";base64," + encoded, nil
}
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, body io.Reader) (*http.Response, error) {
wasStream := info.IsStream
info.IsStream = false
defer func() { info.IsStream = wasStream }()
if a.submit != nil && strings.TrimSpace(a.submit.Method) != "" {
originalMethod := c.Request.Method
c.Request.Method = strings.ToUpper(a.submit.Method)
defer func() { c.Request.Method = originalMethod }()
}
return channel.DoTaskApiRequest(a, c, info, body)
}
func (a *TaskAdaptor) ParseResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (_ *channel.TaskSubmitResponse, taskErr *dto.TaskError) {
started := time.Now()
var responseBody any
var err error
streaming := a.submit != nil && a.submit.ResponseType == "sse"
mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
acceptedStream := streaming || mediaType == "text/event-stream"
defer func() {
if acceptedStream && taskErr != nil {
taskErr.NoRetry = true
}
}()
if !streaming && acceptedStream {
return nil, service.TaskErrorWrapperLocal(fmt.Errorf("unexpected SSE response for a JSON submission"), "plugin_submit_response_invalid", http.StatusBadGateway)
}
if streaming {
// Every failure after accepting this stream is non-retryable: the
// upstream may already have performed billable work.
defer resp.Body.Close()
responseBody, err = a.readSubmitEvents(c.Request.Context(), resp, a.submitContext(c, info))
} else {
var body []byte
body, err = io.ReadAll(io.LimitReader(resp.Body, maxTaskPluginPersistedJSONBytes+1))
if err == nil && len(body) > maxTaskPluginPersistedJSONBytes {
err = fmt.Errorf("task submit response exceeds size limit")
}
responseBody = string(body)
var decoded any
if err == nil && common.Unmarshal(body, &decoded) == nil {
responseBody = decoded
}
}
if err != nil {
failure := service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusBadGateway, common.UpstreamAddresses(c)...)
failure.NoRetry = streaming
return nil, failure
}
headers := make(map[string][]string, len(resp.Header))
maps.Copy(headers, resp.Header)
value, err := a.plugin.Engine.Call(c.Request.Context(), "parseSubmitResponse", a.submitContext(c, info), map[string]any{"statusCode": resp.StatusCode, "headers": headers, "body": responseBody})
if err != nil {
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=parse_submit_failed plugin=%q stage=parse_submit_response reason=hook_failed status=%d elapsed_ms=%d",
a.plugin.Meta.Key,
resp.StatusCode,
time.Since(started).Milliseconds(),
)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_failed", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
if object, ok := value.(map[string]any); ok {
if _, forbidden := object["clientResponse"]; forbidden {
return nil, service.TaskErrorWrapperLocal(fmt.Errorf("parseSubmitResponse must not return clientResponse"), "plugin_submit_response_invalid", http.StatusBadGateway)
}
}
var parsed submitResponse
if err = convert(value, &parsed); err != nil || strings.TrimSpace(parsed.TaskID) == "" {
if err == nil {
err = fmt.Errorf("plugin returned an empty taskId")
}
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=parse_submit_failed plugin=%q stage=parse_submit_response reason=invalid_result status=%d elapsed_ms=%d",
a.plugin.Meta.Key,
resp.StatusCode,
time.Since(started).Milliseconds(),
)
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
var taskData []byte
if parsed.TaskData != nil {
taskData, err = common.Marshal(parsed.TaskData)
if err != nil || len(taskData) > maxTaskPluginPersistedJSONBytes {
if err == nil {
err = fmt.Errorf("task data exceeds size limit")
}
return nil, service.TaskErrorWrapper(err, "plugin_submit_response_invalid", http.StatusBadGateway, common.UpstreamAddresses(c)...)
}
}
pluginState, _ := encodeReturnedPluginState(value)
if len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(c, common.LogText("task plugin %s rejected oversized submit state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
pluginState = nil
}
response := &channel.TaskSubmitResponse{
UpstreamTaskID: parsed.TaskID,
TaskData: taskData,
PluginState: pluginState,
}
logger.LogDebug(c, "task_plugin subsystem=adaptor event=parse_submit_complete plugin=%q status=%d task_data_bytes=%d immediate=%t elapsed_ms=%d",
a.plugin.Meta.Key, resp.StatusCode, len(taskData), parsed.Immediate != nil, time.Since(started).Milliseconds())
if parsed.Immediate == nil {
return response, nil
}
immediate := &relaycommon.TaskInfo{
Code: parsed.Immediate.Code, TaskID: parsed.Immediate.TaskID,
Status: parsed.Immediate.Status, Progress: parsed.Immediate.Progress,
Reason: parsed.Immediate.Reason, Url: parsed.Immediate.URL, RemoteUrl: parsed.Immediate.RemoteURL,
CompletionTokens: positiveInt(parsed.Immediate.CompletionTokens), TotalTokens: positiveInt(parsed.Immediate.TotalTokens),
}
response.Immediate = immediate
if immediate.Status != model.TaskStatusSuccess || !a.hasHook(c.Request.Context(), "extractUsageOnComplete") {
return response, nil
}
// Match the polling hook context without persisting a temporary task.
task := &model.Task{TaskID: info.PublicTaskID, Action: info.Action, Data: taskData,
Properties: model.Properties{OriginModelName: info.OriginModelName, UpstreamModelName: info.UpstreamModelName}}
task.PrivateData.UpstreamTaskID = parsed.TaskID
task.PrivateData.PluginState = pluginState
ctx, err := a.queryContext(task, info.ApiKey, info.ChannelBaseUrl, info.ChannelSetting.Proxy)
if err != nil {
logger.LogWarn(c, common.LogText("task plugin %s completion context failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
return response, nil
}
facts, err := a.plugin.Engine.Call(c.Request.Context(), "extractUsageOnComplete", ctx, jsonValue(immediate), parsed.TaskData)
if err != nil {
logger.LogWarn(c, common.LogText("task plugin %s completion usage failed; retaining reserved quota: %v", a.plugin.Meta.Key, err))
return response, nil
}
if err := a.applyCompletionUsageFacts(immediate, facts, info.UpstreamModelName, info.OriginModelName); err != nil {
logger.LogWarn(c, common.LogText("task plugin %s completion usage rejected; retaining reserved quota: %v", a.plugin.Meta.Key, err))
}
return response, nil
}
func (a *TaskAdaptor) GetModelList() []string { return append([]string(nil), a.plugin.Meta.Models...) }
func (a *TaskAdaptor) GetChannelName() string { return a.plugin.Meta.Name }
func (a *TaskAdaptor) FetchMode() string { return a.plugin.Meta.FetchMode }
func (a *TaskAdaptor) FetchBatchTasks(baseURL, key string, tasks []*model.Task, proxy string) (*http.Response, error) {
taskContexts := make([]map[string]any, 0, len(tasks))
for _, task := range tasks {
taskCtx, err := a.queryContext(task, key, baseURL, proxy)
if err != nil {
return nil, err
}
taskContexts = append(taskContexts, taskCtx)
}
ctx, err := a.batchQueryContext(key, baseURL, proxy, taskContexts)
if err != nil {
return nil, err
}
descriptor, err := a.requestDescriptor(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "buildBatchQueryRequest", ctx, taskContexts)
if err != nil {
return nil, err
}
return a.doFetchDescriptor(baseURL, proxy, descriptor)
}
func (a *TaskAdaptor) FetchTask(baseURL, key string, task *model.Task, proxy string) (*http.Response, error) {
ctx, err := a.queryContext(task, key, baseURL, proxy)
if err != nil {
return nil, err
}
descriptor, err := a.requestDescriptor(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "buildQueryRequest", ctx)
if err != nil {
return nil, err
}
return a.doFetchDescriptor(baseURL, proxy, descriptor)
}
// hookFetcher sends a driver hook's utils.fetch requests to the hosts its
// request descriptors may use, through the channel's proxy like poll requests.
func (a *TaskAdaptor) hookFetcher(baseURL, proxy string) pluginruntime.Fetcher {
return func(ctx context.Context, request pluginruntime.FetchRequest) (pluginruntime.FetchResponse, error) {
if err := pluginruntime.ValidateRequestURL(request.URL, baseURL, a.plugin.Meta.AllowedHosts); err != nil {
return pluginruntime.FetchResponse{}, err
}
client, err := service.GetHttpClientWithProxySettings(proxy, a.channelSetting())
if err != nil {
return pluginruntime.FetchResponse{}, err
}
// A redirect reaches the plugin as it is; fetching its location is a
// new request, checked and counted again.
noRedirects := *client
noRedirects.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
req, err := http.NewRequestWithContext(ctx, request.Method, request.URL, nil)
if err != nil {
return pluginruntime.FetchResponse{}, err
}
for name, value := range request.Headers {
req.Header.Set(name, value)
}
started := time.Now()
resp, err := noRedirects.Do(req)
if err != nil {
logger.LogDebug(ctx, "task_plugin subsystem=fetch event=failed plugin=%q hook=%q method=%q host=%q reason=transport_error elapsed_ms=%d",
a.plugin.Meta.Key, request.Hook, request.Method, req.URL.Host, time.Since(started).Milliseconds())
return pluginruntime.FetchResponse{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, pluginruntime.MaxFetchBodyBytes+1))
if err != nil {
return pluginruntime.FetchResponse{}, err
}
headers := make(map[string]string, len(resp.Header))
for name, values := range resp.Header {
if len(values) > 0 {
headers[name] = values[0]
}
}
logger.LogDebug(ctx, "task_plugin subsystem=fetch event=done plugin=%q hook=%q method=%q host=%q status=%d bytes=%d elapsed_ms=%d",
a.plugin.Meta.Key, request.Hook, request.Method, req.URL.Host, resp.StatusCode, len(body), time.Since(started).Milliseconds())
return pluginruntime.FetchResponse{Status: resp.StatusCode, Headers: headers, Body: body}, nil
}
}
func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, descriptor requestDescriptor) (*http.Response, error) {
if err := pluginruntime.ValidateRequestURL(descriptor.URL, baseURL, a.plugin.Meta.AllowedHosts); err != nil {
return nil, err
}
var requestBody io.Reader
if descriptor.Body != nil {
if bodyText, ok := descriptor.Body.(string); ok {
requestBody = strings.NewReader(bodyText)
} else if descriptor.BodyText != nil {
requestBody = bytes.NewReader(descriptor.BodyText)
} else {
encoded, marshalErr := common.Marshal(descriptor.Body)
if marshalErr != nil {
return nil, marshalErr
}
requestBody = bytes.NewReader(encoded)
}
}
method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
if method == "" {
method = http.MethodGet
}
req, err := http.NewRequest(method, descriptor.URL, requestBody)
if err != nil {
return nil, err
}
for name, value := range descriptor.Headers {
req.Header.Set(name, value)
}
client, err := service.GetHttpClientWithProxySettings(proxy, a.channelSetting())
if err != nil {
return nil, err
}
started := time.Now()
resp, err := client.Do(req)
if err != nil {
logger.LogDebug(
context.Background(),
"task_plugin subsystem=adaptor event=query_request_failed plugin=%q method=%q reason=transport_error elapsed_ms=%d",
a.plugin.Meta.Key,
method,
time.Since(started).Milliseconds(),
)
return nil, err
}
logger.LogDebug(
context.Background(),
"task_plugin subsystem=adaptor event=query_response_received plugin=%q method=%q status=%d elapsed_ms=%d",
a.plugin.Meta.Key,
method,
resp.StatusCode,
time.Since(started).Milliseconds(),
)
return resp, nil
}
func (a *TaskAdaptor) ParseBatchResult(tasks []*model.Task, resp *http.Response, body []byte) (map[string]*service.BatchTaskResult, error) {
started := time.Now()
input := any(string(body))
var decoded any
if common.Unmarshal(body, &decoded) == nil {
input = decoded
}
key, baseURL, proxy := a.queryCredentials()
taskContexts := make([]map[string]any, 0, len(tasks))
for _, task := range tasks {
taskCtx, err := a.queryContext(task, key, baseURL, proxy)
if err != nil {
return nil, err
}
taskContexts = append(taskContexts, taskCtx)
}
ctx, err := a.batchQueryContext(key, baseURL, proxy, taskContexts)
if err != nil {
return nil, err
}
var parsed []struct {
TaskID string `json:"taskId"`
Action string `json:"action"`
Status string `json:"status"`
Progress string `json:"progress"`
Reason string `json:"reason"`
URL string `json:"url"`
SubmitTime int64 `json:"submitTime"`
StartTime int64 `json:"startTime"`
FinishTime int64 `json:"finishTime"`
Data any `json:"data"`
State any `json:"state"`
}
if err = a.plugin.Engine.CallInto(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), &parsed, "parseBatchResult", ctx, input, hookHTTPResponse(resp)); err != nil {
reason := "hook_failed"
var invalid *pluginruntime.ResultError
if errors.As(err, &invalid) {
reason = "invalid_result"
}
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_batch_failed plugin=%q reason=%s body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, reason, len(body), time.Since(started).Milliseconds())
return nil, err
}
results := make(map[string]*service.BatchTaskResult, len(parsed))
hasCompletionUsage := a.hasHook(context.Background(), "extractUsageOnComplete")
for _, item := range parsed {
if strings.TrimSpace(item.TaskID) == "" {
continue
}
info := relaycommon.TaskInfo{TaskID: item.TaskID, Status: item.Status, Progress: item.Progress, Reason: item.Reason, Url: item.URL}
if item.State != nil {
pluginState, marshalErr := common.Marshal(item.State)
if marshalErr != nil || len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(context.Background(), common.LogText("task plugin %s rejected invalid or oversized poll state", a.plugin.Meta.Key))
} else {
info.PluginState = pluginState
}
}
if hasCompletionUsage {
usageBody := item.Data
if usageBody == nil {
usageBody = jsonValue(item)
}
itemCtx := ctx
for _, taskCtx := range taskContexts {
if fmt.Sprint(taskCtx["taskId"]) == item.TaskID {
itemCtx = taskCtx
break
}
}
facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", itemCtx, jsonValue(&info), usageBody)
if hookErr == nil {
upstreamModel, _ := itemCtx["upstreamModel"].(string)
originModel, _ := itemCtx["model"].(string)
a.applyCompletionUsageFacts(&info, facts, upstreamModel, originModel)
}
}
results[item.TaskID] = &service.BatchTaskResult{TaskInfo: info, Action: item.Action, SubmitTime: item.SubmitTime, StartTime: item.StartTime, FinishTime: item.FinishTime, Data: item.Data}
}
logger.LogDebug(
context.Background(),
"task_plugin subsystem=adaptor event=parse_batch_complete plugin=%q body_bytes=%d results=%d completion_usage_hook=%t elapsed_ms=%d",
a.plugin.Meta.Key,
len(body),
len(results),
hasCompletionUsage,
time.Since(started).Milliseconds(),
)
return results, nil
}
func (a *TaskAdaptor) ParseTaskResult(task *model.Task, resp *http.Response, body []byte) (*relaycommon.TaskInfo, error) {
started := time.Now()
input := any(string(body))
var decoded any
if common.Unmarshal(body, &decoded) == nil {
input = decoded
}
key, baseURL, proxy := a.queryCredentials()
if task != nil && task.PrivateData.Key != "" {
key = task.PrivateData.Key
}
ctx, err := a.queryContext(task, key, baseURL, proxy)
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(pluginruntime.WithFetcher(context.Background(), a.hookFetcher(baseURL, proxy)), "parseTaskResult", ctx, input, hookHTTPResponse(resp))
if err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_task_failed plugin=%q reason=hook_failed body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
return nil, err
}
var parsed taskResult
if err = convert(value, &parsed); err != nil {
logger.LogDebug(context.Background(), "task_plugin subsystem=adaptor event=parse_task_failed plugin=%q reason=invalid_result body_bytes=%d elapsed_ms=%d", a.plugin.Meta.Key, len(body), time.Since(started).Milliseconds())
return nil, err
}
result := &relaycommon.TaskInfo{
Code: parsed.Code,
TaskID: parsed.TaskID,
Status: parsed.Status,
Progress: parsed.Progress,
Reason: parsed.Reason,
Url: parsed.URL,
RemoteUrl: parsed.RemoteURL,
CompletionTokens: positiveInt(parsed.CompletionTokens),
TotalTokens: positiveInt(parsed.TotalTokens),
}
if pluginState, present := encodeReturnedPluginState(value); present {
if len(pluginState) > maxTaskPluginPersistedJSONBytes {
logger.LogWarn(context.Background(), common.LogText("task plugin %s rejected oversized poll state (%d bytes)", a.plugin.Meta.Key, len(pluginState)))
} else {
result.PluginState = pluginState
}
}
// The raw polling response only exists at this boundary. Capture upstream
// units here so the host settlement path can consume them from TaskInfo.
if a.hasHook(context.Background(), "extractUsageOnComplete") {
facts, hookErr := a.plugin.Engine.Call(context.Background(), "extractUsageOnComplete", ctx, jsonValue(result), input)
if hookErr == nil {
upstreamModel, _ := ctx["upstreamModel"].(string)
originModel, _ := ctx["model"].(string)
a.applyCompletionUsageFacts(result, facts, upstreamModel, originModel)
}
}
taskStatus := model.TaskStatus(result.Status)
logger.LogDebug(
context.Background(),
"task_plugin subsystem=adaptor event=parse_task_complete plugin=%q terminal=%t body_bytes=%d elapsed_ms=%d",
a.plugin.Meta.Key,
taskStatus == model.TaskStatusSuccess || taskStatus == model.TaskStatusFailure,
len(body),
time.Since(started).Milliseconds(),
)
return result, nil
}
func (a *TaskAdaptor) applyCompletionUsageFacts(result *relaycommon.TaskInfo, facts any, models ...string) error {
values, err := a.validatedCompletionUsageFacts(facts, models...)
if err != nil {
a.logRejectedUsage("extractUsageOnComplete", err)
return err
}
if len(values) == 0 {
return nil
}
result.UsageFacts = values
if units := positiveInt(values["upstreamUnits"]); units > 0 {
result.CompletionTokens = units
result.TotalTokens = units
return nil
}
if completionTokens, exists := values["completionTokens"]; exists {
result.CompletionTokens = positiveInt(completionTokens)
}
if totalTokens, exists := values["totalTokens"]; exists {
result.TotalTokens = positiveInt(totalTokens)
}
return nil
}
func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
if task == nil {
return nil, fmt.Errorf("task is required")
}
claimed := slices.ContainsFunc(a.plugin.Meta.Protocols, func(claim pluginruntime.ProtocolClaim) bool {
return claim.Name == "openai_video"
})
if !claimed {
return nil, fmt.Errorf("plugin does not claim openai_video")
}
view, err := service.BuildTaskPluginView(task)
if err != nil {
return nil, err
}
encodedView, err := common.Marshal(view)
if err != nil {
return nil, err
}
// Decoding into any leaves only encoding failures to CallPathInto, so they
// keep the codec's message.
var value any
err = a.plugin.Engine.CallPathInto(context.Background(), &value, "protocols", []string{"openai_video", "render"}, map[string]any{"protocol": "openai_video", "operation": "retrieve"}, pluginruntime.RawJSON(encodedView))
if err != nil {
return nil, err
}
rendered, _ := value.(map[string]any)
if rendered == nil {
return nil, fmt.Errorf("plugin returned an invalid OpenAI video object")
}
// Keep provider extensions intact while the host owns the public task's
// identity and lifecycle, including completion timestamps after settlement.
host := task.ToOpenAIVideo()
rendered["id"] = host.ID
rendered["object"] = host.Object
delete(rendered, "task_id")
rendered["status"] = host.Status
rendered["progress"] = host.Progress
rendered["created_at"] = host.CreatedAt
rendered["model"] = host.Model
if host.CompletedAt != 0 {
rendered["completed_at"] = host.CompletedAt
} else {
delete(rendered, "completed_at")
}
return common.Marshal(rendered)
}
func (a *TaskAdaptor) ListArtifacts(task *model.Task) ([]channel.TaskArtifact, error) {
if !a.hasHook(context.Background(), "listArtifacts") {
return nil, nil
}
ctx, err := taskArtifactContext(task)
if err != nil {
return nil, err
}
value, err := a.plugin.Engine.Call(context.Background(), "listArtifacts", ctx)
if err != nil {
return nil, fmt.Errorf("plugin artifact listing failed")
}
return validateTaskArtifacts(value)
}
func (a *TaskAdaptor) BuildContentRequest(ctx context.Context, task *model.Task, artifactKey string, clientRequest channel.TaskArtifactClientRequest) (*channel.TaskContentRequest, error) {
if !a.hasHook(context.Background(), "buildContentRequest") {
return nil, nil
}
if a.info == nil {
return nil, fmt.Errorf("plugin adaptor is not initialized")
}
if !taskArtifactKeyPattern.MatchString(artifactKey) {
return nil, fmt.Errorf("invalid artifact key")
}
hookContext, err := taskArtifactContext(task)
if err != nil {
return nil, err
}
hookContext["upstreamTaskId"] = task.GetUpstreamTaskID()
hookContext["artifactKey"] = artifactKey
hookContext["baseUrl"] = a.info.ChannelBaseUrl
hookContext["clientRequest"] = jsonValue(clientRequest)
if err = a.applyUpstreamCredentials(hookContext, a.info.ChannelType, a.info.ApiKey, a.info.ChannelSetting.Proxy); err != nil {
return nil, err
}
fetchContext := pluginruntime.WithFetcher(ctx, a.hookFetcher(a.info.ChannelBaseUrl, a.info.ChannelSetting.Proxy))
descriptor, err := a.requestDescriptor(fetchContext, "buildContentRequest", hookContext)
if err != nil {
return nil, err
}
method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
if method == "" {
method = strings.ToUpper(strings.TrimSpace(clientRequest.Method))
}
if method == "" {
method = http.MethodGet
}
if method != http.MethodGet && method != http.MethodHead && method != http.MethodPost {
return nil, fmt.Errorf("plugin returned an unsupported artifact request method")
}
if descriptor.Credentialless {
if method != http.MethodGet && method != http.MethodHead {
return nil, fmt.Errorf("credentialless artifact requests must use GET or HEAD")
}
if len(descriptor.Headers) != 0 || descriptor.Body != nil {
return nil, fmt.Errorf("credentialless artifact requests cannot contain headers or a body")
}
parsedURL, parseErr := url.Parse(descriptor.URL)
if parseErr != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
return nil, fmt.Errorf("credentialless artifact request URL must be absolute HTTP(S)")
}
} else if err = pluginruntime.ValidateRequestURL(descriptor.URL, a.info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts); err != nil {
return nil, err
}
var body []byte
if descriptor.Body != nil {
if text, ok := descriptor.Body.(string); ok {
body = []byte(text)
} else if descriptor.BodyText != nil {
body = descriptor.BodyText
} else {
body, err = common.Marshal(descriptor.Body)
if err != nil {
return nil, fmt.Errorf("plugin returned an invalid artifact request body")
}
}
}
return &channel.TaskContentRequest{
URL: descriptor.URL,
Method: method,
Headers: descriptor.Headers,
Body: body,
Credentialless: descriptor.Credentialless,
}, nil
}
func taskArtifactContext(task *model.Task) (map[string]any, error) {
if task == nil {
return nil, fmt.Errorf("task is required")
}
var data any
if len(task.Data) > 0 {
if err := common.Unmarshal(task.Data, &data); err != nil {
return nil, fmt.Errorf("task data is invalid")
}
}
producerVersion := ""
if task.PrivateData.Execution != nil && task.PrivateData.Execution.TaskPlugin != nil {
producerVersion = task.PrivateData.Execution.TaskPlugin.Version
}
var state any
if len(task.PrivateData.PluginState) > 0 {
if err := common.Unmarshal(task.PrivateData.PluginState, &state); err != nil {
return nil, fmt.Errorf("plugin state is invalid")
}
}
return map[string]any{
"taskId": task.TaskID,
"status": string(task.Status),
"action": task.Action,
"data": data,
"state": state,
"producerVersion": producerVersion,
}, nil
}
func (a *TaskAdaptor) queryContext(task *model.Task, key, baseURL, proxy string) (map[string]any, error) {
ctx := map[string]any{
"taskId": "",
"publicTaskId": "",
"action": "",
"model": "",
"upstreamModel": "",
"baseUrl": baseURL,
"data": nil,
"state": nil,
}
if task != nil {
originModel := task.Properties.OriginModelName
upstreamModel := task.Properties.UpstreamModelName
if upstreamModel == "" {
upstreamModel = originModel
}
ctx["taskId"] = task.GetUpstreamTaskID()
ctx["publicTaskId"] = task.TaskID
ctx["action"] = constant.NormalizeTaskAction(task.Action)
ctx["model"] = originModel
ctx["upstreamModel"] = upstreamModel
if len(task.Data) > 0 {
var data any
if err := common.Unmarshal(task.Data, &data); err != nil {
return nil, fmt.Errorf("task data is invalid")
}
ctx["data"] = data
}
if len(task.PrivateData.PluginState) > 0 {
var state any
if err := common.Unmarshal(task.PrivateData.PluginState, &state); err != nil {
return nil, fmt.Errorf("plugin state is invalid")
}
ctx["state"] = state
}
if task.PrivateData.Key != "" {
key = task.PrivateData.Key
}
}
if err := a.applyUpstreamCredentials(ctx, a.channelType(), key, proxy); err != nil {
return nil, err
}
return ctx, nil
}
func (a *TaskAdaptor) batchQueryContext(key, baseURL, proxy string, tasks []map[string]any) (map[string]any, error) {
ctx := map[string]any{"baseUrl": baseURL, "tasks": tasks}
if err := a.applyUpstreamCredentials(ctx, a.channelType(), key, proxy); err != nil {
return nil, err
}
return ctx, nil
}
func (a *TaskAdaptor) queryCredentials() (key, baseURL, proxy string) {
if a.info == nil || !a.info.HasChannelMeta() {
return "", "", ""
}
return a.info.ApiKey, a.info.ChannelBaseUrl, a.info.ChannelSetting.Proxy
}
// channelType is the executing channel's type when polling or content
// fetches run with channel metadata; zero otherwise, which is a vendor upstream.
func (a *TaskAdaptor) channelType() int {
if a.info == nil || !a.info.HasChannelMeta() {
return 0
}
return a.info.ChannelType
}
// channelSetting is the executing channel's settings when the adaptor runs
// with channel metadata; zero otherwise.
func (a *TaskAdaptor) channelSetting() kitdto.ChannelSettings {
if a.info == nil || !a.info.HasChannelMeta() {
return kitdto.ChannelSettings{}
}
return a.info.ChannelSetting
}
// applyUpstreamCredentials sets ctx.upstream together with the credentials
// every driver hook reads. A New API channel (type 60) is another gateway:
// the plugin must address its own prefixed native routes there, and the
// channel key is that gateway's token, so it is sent as a Bearer header for
// every auth type instead of running the plugin's vendor auth scheme.
func (a *TaskAdaptor) applyUpstreamCredentials(ctx map[string]any, channelType int, key, proxy string) error {
if channelType == constant.ChannelTypeNewAPI {
ctx["upstream"] = map[string]any{"kind": pluginruntime.UpstreamKindNewAPI}
ctx["auth"] = map[string]any{"authHeader": "Bearer " + key}
ctx["authHeader"] = "Bearer " + key
ctx["apiKey"] = key
return nil
}
ctx["upstream"] = map[string]any{"kind": pluginruntime.UpstreamKindVendor}
auth, err := resolveAuth(a.plugin.Meta.Auth, key, proxy)
if err != nil {
return err
}
ctx["auth"] = auth
ctx["authHeader"] = auth["authHeader"]
if a.plugin.Meta.Auth.Type == "" || a.plugin.Meta.Auth.Type == "none" || a.plugin.Meta.Auth.Type == "api_key" {
ctx["apiKey"] = key
}
return nil
}
func hookHTTPResponse(resp *http.Response) map[string]any {
headers := map[string]string{}
status := 0
if resp != nil {
status = resp.StatusCode
for name, values := range resp.Header {
if len(values) > 0 {
headers[name] = values[0]
}
}
}
return map[string]any{"status": status, "headers": headers}
}
func encodeReturnedPluginState(value any) ([]byte, bool) {
object, ok := value.(map[string]any)
if !ok {
return nil, false
}
state, exists := object["state"]
if !exists || state == nil {
return nil, false
}
data, err := common.Marshal(state)
if err != nil {
return nil, false
}
return data, true
}
func validateTaskArtifacts(value any) ([]channel.TaskArtifact, error) {
encoded, err := common.Marshal(value)
if err != nil {
return nil, fmt.Errorf("plugin returned invalid artifacts")
}
if common.GetJsonType(encoded) != "array" {
return nil, fmt.Errorf("plugin listArtifacts must return an array")
}
var items []map[string]any
if err = common.Unmarshal(encoded, &items); err != nil {
return nil, fmt.Errorf("plugin listArtifacts must return an array")
}
if len(items) > maxTaskArtifacts {
return nil, fmt.Errorf("plugin returned too many artifacts")
}
artifacts := make([]channel.TaskArtifact, 0, len(items))
keys := make(map[string]struct{}, len(items))
for _, item := range items {
for field := range item {
if field != "key" && field != "type" && field != "mimeType" {
return nil, fmt.Errorf("plugin artifact contains unsupported field %q", field)
}
}
key, keyOK := item["key"].(string)
artifactType, typeOK := item["type"].(string)
if !keyOK || !taskArtifactKeyPattern.MatchString(key) {
return nil, fmt.Errorf("plugin artifact has invalid key")
}
if _, exists := keys[key]; exists {
return nil, fmt.Errorf("plugin artifact keys must be unique")
}
keys[key] = struct{}{}
switch artifactType {
case "video", "audio", "image", "file":
default:
if !typeOK {
return nil, fmt.Errorf("plugin artifact has invalid type")
}
return nil, fmt.Errorf("plugin artifact has unsupported type")
}
mimeType := ""
if rawMimeType, exists := item["mimeType"]; exists {
var mimeTypeOK bool
mimeType, mimeTypeOK = rawMimeType.(string)
if !mimeTypeOK {
return nil, fmt.Errorf("plugin artifact has invalid mimeType")
}
}
artifacts = append(artifacts, channel.TaskArtifact{
Key: key,
Type: artifactType,
MimeType: mimeType,
})
}
return artifacts, nil
}
// requestDescriptor runs a request-building hook. A plugin that preserves JSON
// order has its descriptor decoded from the JSON.stringify text of the result,
// with the body kept as that text (BodyText) for the upstream.
func (a *TaskAdaptor) requestDescriptor(ctx context.Context, hook string, args ...any) (requestDescriptor, error) {
if !a.plugin.Meta.PreservesJSONOrder() {
var descriptor requestDescriptor
err := a.plugin.Engine.CallInto(ctx, &descriptor, hook, args...)
return descriptor, err
}
var ordered orderedRequestDescriptor
if err := a.plugin.Engine.CallJSONInto(ctx, &ordered, hook, args...); err != nil {
return requestDescriptor{}, err
}
descriptor := requestDescriptor{
ResponseType: ordered.ResponseType, URL: ordered.URL, Method: ordered.Method, Headers: ordered.Headers,
Credentialless: ordered.Credentialless, Action: ordered.Action, Model: ordered.Model,
RewriteModel: ordered.RewriteModel, BodyType: ordered.BodyType, Parts: ordered.Parts,
}
switch {
case len(ordered.Body) == 0 || string(ordered.Body) == "null":
case ordered.Body[0] == '"':
// A string body is the plugin's own text, sent as it is.
var text string
if err := common.Unmarshal(ordered.Body, &text); err != nil {
return requestDescriptor{}, err
}
descriptor.Body = text
default:
descriptor.Body, descriptor.BodyText = ordered.Body, ordered.Body
}
return descriptor, nil
}
func (a *TaskAdaptor) buildSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*requestDescriptor, error) {
if a.submit != nil {
return a.submit, nil
}
started := time.Now()
// buildSubmitRequest runs before quota is reserved, so a failed fetch
// costs nothing; requests that start vendor work stay the descriptor.
fetchContext := c.Request.Context()
if info.HasChannelMeta() {
fetchContext = pluginruntime.WithFetcher(fetchContext, a.hookFetcher(info.ChannelBaseUrl, info.ChannelSetting.Proxy))
}
descriptor, err := a.requestDescriptor(fetchContext, "buildSubmitRequest", a.submitContext(c, info))
if err != nil {
reason := "hook_failed"
var invalid *pluginruntime.ResultError
if errors.As(err, &invalid) {
reason = "invalid_descriptor"
}
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=build_submit_request reason=%s elapsed_ms=%d",
a.plugin.Meta.Key,
reason,
time.Since(started).Milliseconds(),
)
return nil, err
}
if descriptor.ResponseType == "" {
descriptor.ResponseType = "json"
}
allowed := a.plugin.Meta.SubmitResponseTypes
if len(allowed) == 0 {
allowed = []string{"json"}
}
if !slices.Contains(allowed, descriptor.ResponseType) {
return nil, fmt.Errorf("plugin does not support submit response type %q", descriptor.ResponseType)
}
if strings.TrimSpace(descriptor.URL) == "" {
logger.LogDebug(c, "task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=validate_url reason=empty_url", a.plugin.Meta.Key)
return nil, fmt.Errorf("plugin returned an empty submit URL")
}
if err = pluginruntime.ValidateRequestURL(descriptor.URL, info.ChannelBaseUrl, a.plugin.Meta.AllowedHosts); err != nil {
logger.LogDebug(c, "task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=validate_url reason=url_not_allowed", a.plugin.Meta.Key)
return nil, err
}
if descriptor.Action != "" {
info.Action = descriptor.Action
}
if descriptor.Model != "" {
if _, pinnedEndpoint := c.Get(pluginruntime.ContextKeyPinnedEndpoint); pinnedEndpoint {
resolvedModel := c.GetString("resolved_task_model")
if resolvedModel == "" || descriptor.Model != resolvedModel {
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=build_submit_failed plugin=%q stage=model_pin reason=model_mismatch",
a.plugin.Meta.Key,
)
return nil, fmt.Errorf("plugin submit model does not match the pinned endpoint model")
}
}
info.OriginModelName = descriptor.Model
}
if descriptor.RewriteModel != "" {
info.UpstreamModelName = descriptor.RewriteModel
}
a.submit = &descriptor
method := strings.ToUpper(strings.TrimSpace(descriptor.Method))
if method == "" {
method = http.MethodPost
}
logger.LogDebug(
c,
"task_plugin subsystem=adaptor event=build_submit_complete plugin=%q method=%q body_type=%q parts=%d model=%q action_present=%t rewrite_model=%t elapsed_ms=%d",
a.plugin.Meta.Key,
method,
descriptor.BodyType,
len(descriptor.Parts),
info.OriginModelName,
info.Action != "",
descriptor.RewriteModel != "",
time.Since(started).Milliseconds(),
)
return a.submit, nil
}
func (a *TaskAdaptor) submitContext(c *gin.Context, info *relaycommon.RelayInfo) map[string]any {
routeRequest := pluginruntime.RouteRequestContext{
Params: map[string]string{},
Query: map[string][]string{},
RequestBody: map[string]any{},
}
requestHeaders := map[string]string{}
files := make([]map[string]any, 0)
if a.routeRequest != nil {
routeRequest = *a.routeRequest
requestHeaders = make(map[string]string, len(a.requestHeaders))
maps.Copy(requestHeaders, a.requestHeaders)
files = append(files, a.files...)
}
if c != nil {
requestHeaders = map[string]string{}
files = make([]map[string]any, 0)
if prepared, exists := c.Get(pluginruntime.ContextKeyRouteRequest); exists {
if canonical, ok := prepared.(pluginruntime.RouteRequestContext); ok {
routeRequest = canonical
}
}
if taskRequest, exists := c.Get("task_request"); exists {
routeRequest.RequestBody = taskRequest
}
a.requestBodyText = nil
if text, exists := c.Get(pluginruntime.ContextKeyRequestBodyText); exists && a.plugin.Meta.PreservesJSONOrder() {
a.requestBodyText, _ = text.(json.RawMessage)
}
if c.Request != nil {
if routeRequest.Path == "" {
routeRequest.Path = c.Request.URL.Path
}
if routeRequest.Method == "" {
routeRequest.Method = c.Request.Method
}
if len(routeRequest.Params) == 0 {
routeRequest.Params = make(map[string]string, len(c.Params))
for _, param := range c.Params {
routeRequest.Params[param.Key] = param.Value
}
}
if len(routeRequest.Query) == 0 {
routeRequest.Query = make(map[string][]string, len(c.Request.URL.Query()))
for key, values := range c.Request.URL.Query() {
routeRequest.Query[key] = append([]string(nil), values...)
}
}
requestHeaders["Content-Type"] = c.GetHeader("Content-Type")
requestHeaders["Accept"] = c.GetHeader("Accept")
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
if form, err := common.ParseMultipartFormReusable(c); err == nil {
defer form.RemoveAll()
for field, headers := range form.File {
for index, header := range headers {
files = append(files, map[string]any{"ref": pluginruntime.FileReference(field, index), "field": field, "filename": header.Filename, "mimeType": header.Header.Get("Content-Type"), "size": header.Size})
}
}
}
}
}
snapshot := routeRequest
a.routeRequest = &snapshot
a.requestHeaders = make(map[string]string, len(requestHeaders))
maps.Copy(a.requestHeaders, requestHeaders)
a.files = append(a.files[:0], files...)
}
ctx := routeRequest.JSValue()
// Hooks never write into the request body, so the same object normalizes
// to the same value on every hook call of this request.
requestBody := routeRequest.RequestBody
source, isObject := requestBody.(map[string]any)
if a.requestBodyText != nil {
// Driver hooks read the request body, so the engine parses the text
// in place for each of them, members in order.
requestBody = pluginruntime.RawJSON(a.requestBodyText)
} else if isObject && a.hookRequestSource != nil && reflect.ValueOf(source).UnsafePointer() == reflect.ValueOf(a.hookRequestSource).UnsafePointer() {
requestBody = a.hookRequestBody
} else {
if !isPlainJSONValue(requestBody, 0) {
requestBody = jsonValue(requestBody)
}
if isObject {
a.hookRequestSource, a.hookRequestBody = source, requestBody
}
}
ctx["requestBody"] = requestBody
ctx["requestHeaders"] = requestHeaders
ctx["files"] = files
ctx["action"] = info.Action
ctx["originTaskId"] = info.OriginTaskID
if info.TaskRelayInfo != nil && len(info.OriginTasks) > 0 {
originTasks := make([]map[string]any, 0, len(info.OriginTasks))
for _, ref := range info.OriginTasks {
var data any
if len(ref.Data) > 0 {
if err := common.Unmarshal(ref.Data, &data); err != nil {
data = nil
}
}
originTasks = append(originTasks, map[string]any{
"taskId": ref.TaskID,
"upstreamTaskId": ref.UpstreamTaskID,
"action": ref.Action,
"status": ref.Status,
"data": data,
})
}
ctx["originTasks"] = originTasks
}
ctx["publicTaskId"] = info.PublicTaskID
ctx["model"] = info.OriginModelName
ctx["upstreamModel"] = info.UpstreamModelName
ctx["baseUrl"] = info.ChannelBaseUrl
ctx["userSetting"] = info.UserSetting
if err := a.applyUpstreamCredentials(ctx, info.ChannelType, info.ApiKey, info.ChannelSetting.Proxy); err != nil {
ctx["authError"] = err.Error()
}
return ctx
}
func (a *TaskAdaptor) usageRatios(ctx context.Context, models []string, hook string, args ...any) (map[string]float64, error) {
if !a.hasHook(ctx, hook) {
return nil, nil
}
started := time.Now()
value, err := a.plugin.Engine.Call(ctx, hook, args...)
if err != nil {
logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=hook_failed elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
return nil, fmt.Errorf("plugin usage hook failed")
}
if value == nil {
return nil, nil
}
facts, ok := value.(map[string]any)
if !ok {
logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=result_not_object elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
return nil, fmt.Errorf("plugin usage hook must return an object")
}
_, ratios, err := a.validatedUsageRatios(facts, models...)
if err != nil {
logger.LogDebug(ctx, "task_plugin subsystem=adaptor event=usage_hook_failed plugin=%q hook=%q reason=invalid_usage elapsed_ms=%d", a.plugin.Meta.Key, hook, time.Since(started).Milliseconds())
return nil, err
}
logger.LogDebug(
ctx,
"task_plugin subsystem=adaptor event=usage_hook_complete plugin=%q hook=%q facts=%d positive_ratios=%d elapsed_ms=%d",
a.plugin.Meta.Key,
hook,
len(facts),
len(ratios),
time.Since(started).Milliseconds(),
)
return ratios, nil
}
func (a *TaskAdaptor) validateResolvedUsageRequest(request any, models ...string) error {
schema, _ := a.plugin.Meta.UsageForModels(models...)
return a.validateResolvedUsageValue(jsonValue(request), schema)
}
func (a *TaskAdaptor) validateResolvedUsageValue(value any, usageSchema map[string]pluginruntime.UsageFieldSchema) error {
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
limit, canonical := canonicalUsageLimit(key)
// A duration of -1 asks the vendor to pick the length. Plugins that
// declare duration-auto@1 reserve the longest duration for it and
// settle on the usage the vendor reports; usage facts stay
// non-negative.
if canonical && limit == relaycommon.MaxTaskDurationSeconds && a.plugin.Meta.AcceptsAutoDuration() {
if number, _ := usageNumber(item, true); number == -1 {
continue
}
}
if schema, declared := usageSchema[key]; declared {
if _, err := validateUsageValue(item, schema, true); err != nil {
return err
}
} else if canonical {
if err := validateUsageLimit(item, limit, true); err != nil {
return err
}
}
if err := a.validateResolvedUsageValue(item, usageSchema); err != nil {
return err
}
}
case []any:
for _, item := range typed {
if err := a.validateResolvedUsageValue(item, usageSchema); err != nil {
return err
}
}
}
return nil
}
// validatedUsageRatios returns the normalized facts in a new map because the
// hook result may be a host value, such as the request body, that must stay
// unchanged.
func (a *TaskAdaptor) validatedUsageRatios(facts map[string]any, models ...string) (map[string]any, map[string]float64, error) {
usageSchema, _ := a.plugin.Meta.UsageForModels(models...)
validated := make(map[string]any, len(facts))
ratios := make(map[string]float64)
for key, value := range facts {
validated[key] = value
if schema, declared := usageSchema[key]; declared {
number, err := validateUsageValue(value, schema, false)
if err != nil {
return nil, nil, err
}
if schema.Type == "number" {
validated[key] = number
if number > 0 {
ratios[key] = number
}
}
continue
}
number, numeric := usageNumber(value, false)
if !numeric {
continue
}
limit, canonical := canonicalUsageLimit(key)
if !canonical {
// Undeclared numeric facts remain extensible, but still use the
// largest canonical task multiplier ceiling so they cannot be
// unbounded before quota calculation.
limit = relaycommon.MaxTaskDurationSeconds
}
if err := validateUsageNumberLimit(number, limit); err != nil {
return nil, nil, err
}
// Keep numeric facts usable by existing expressions even when the
// selected profile no longer declares that legacy extension field.
validated[key] = number
if number > 0 {
ratios[key] = number
}
}
return validated, ratios, nil
}
func (a *TaskAdaptor) validatedCompletionUsageFacts(facts any, models ...string) (map[string]any, error) {
usageSchema, _ := a.plugin.Meta.UsageForModels(models...)
if facts == nil {
return nil, nil
}
values, ok := facts.(map[string]any)
if !ok {
return nil, fmt.Errorf("plugin usage hook must return an object")
}
validated := make(map[string]any, len(values))
for key, value := range values {
validated[key] = value
if schema, declared := usageSchema[key]; declared {
number, err := validateUsageValue(value, schema, false)
if err != nil {
return nil, err
}
if schema.Type == "number" {
validated[key] = number
}
continue
}
if limit, canonical := canonicalUsageLimit(key); canonical {
number, numeric := usageNumber(value, false)
if !numeric {
return nil, fmt.Errorf("plugin usage value must be a number")
}
if err := validateUsageNumberLimit(number, limit); err != nil {
return nil, err
}
validated[key] = number
continue
}
switch key {
case "upstreamUnits", "completionTokens", "totalTokens":
number, numeric := usageNumber(value, false)
if !numeric || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return nil, fmt.Errorf("plugin usage value must be a finite non-negative number")
}
validated[key] = float64(common.QuotaFromFloat(number))
default:
if number, numeric := usageNumber(value, false); numeric {
validated[key] = number
}
}
}
return validated, nil
}
func validateUsageValue(value any, schema pluginruntime.UsageFieldSchema, allowNumericString bool) (float64, error) {
if len(schema.Enum) > 0 {
text, ok := value.(string)
if !ok {
return 0, fmt.Errorf("plugin usage enum must be a string")
}
if slices.Contains(schema.Enum, text) {
return 0, nil
}
return 0, fmt.Errorf("plugin usage enum is not an allowed value")
}
if schema.Type == "boolean" {
if _, ok := value.(bool); !ok {
return 0, fmt.Errorf("plugin usage value must be a boolean")
}
return 0, nil
}
number, ok := usageNumber(value, allowNumericString)
if !ok {
return 0, fmt.Errorf("plugin usage value must be a number")
}
if schema.Unit == "token" || schema.Unit == "credit" {
if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return 0, fmt.Errorf("plugin usage value must be a finite non-negative number")
}
// Bound-check with QuotaFromFloatChecked (int32 saturation) but keep
// the original fractional part so credit facts like 3.5 survive.
if quota, clamp := common.QuotaFromFloatChecked(number); clamp != nil {
return float64(quota), nil
}
return number, nil
}
limit := relaycommon.MaxTaskDurationSeconds
if schema.Unit == "count" {
limit = kitdto.MaxImageN
}
if err := validateUsageNumberLimit(number, limit); err != nil {
return 0, err
}
return number, nil
}
func validateUsageLimit(value any, limit int, allowNumericString bool) error {
number, ok := usageNumber(value, allowNumericString)
if !ok {
return fmt.Errorf("plugin usage value must be a number")
}
return validateUsageNumberLimit(number, limit)
}
func validateUsageNumberLimit(number float64, limit int) error {
if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return fmt.Errorf("plugin usage value must be a finite non-negative number")
}
if number > float64(limit) {
return fmt.Errorf("plugin usage value exceeds the host limit")
}
return nil
}
func usageNumber(value any, allowNumericString bool) (float64, bool) {
switch number := value.(type) {
case float64:
return number, true
case int64:
return float64(number), true
case int:
return float64(number), true
case string:
if !allowNumericString {
return 0, false
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(number), 64)
return parsed, err == nil
default:
return 0, false
}
}
var usageKeySeparators = strings.NewReplacer("_", "", "-", "")
func canonicalUsageLimit(key string) (int, bool) {
normalized := usageKeySeparators.Replace(strings.ToLower(key))
switch normalized {
case "duration", "durationseconds", "second", "seconds":
return relaycommon.MaxTaskDurationSeconds, true
case "n", "count", "imagecount", "samplecount", "batchcount", "numimages":
return kitdto.MaxImageN, true
default:
return 0, false
}
}
func (a *TaskAdaptor) logRejectedUsage(hook string, _ error) {
common.SysError(common.LogText("task plugin %s rejected invalid %s billing facts", a.plugin.Meta.Key, hook))
}
func (a *TaskAdaptor) hasHook(_ context.Context, hook string) bool {
return a.plugin.Engine.HasExport(hook)
}
func convert(value any, target any) error {
data, err := common.Marshal(value)
if err != nil {
return err
}
return common.Unmarshal(data, target)
}
func jsonValue(value any) any {
if normalized, ok := cloneJSONValue(value, 0); ok {
return normalized
}
data, err := common.Marshal(value)
if err != nil {
return value
}
var normalized any
if err = common.Unmarshal(data, &normalized); err != nil {
return value
}
return normalized
}
// isPlainJSONValue reports whether hooks can take value as is: it reaches
// JavaScript exactly as the codec's decoding of it would, and the engine never
// writes back into it. Integers count, since hooks see the same numbers; a nil
// slice does not, since it would reach hooks as [] instead of null.
func isPlainJSONValue(value any, depth int) bool {
if depth > 64 {
return false
}
switch typed := value.(type) {
case nil, bool, int64, int:
return true
case string:
return utf8.ValidString(typed)
case float64:
return !math.IsNaN(typed) && !math.IsInf(typed, 0)
case map[string]any:
for key, item := range typed {
if !utf8.ValidString(key) || !isPlainJSONValue(item, depth+1) {
return false
}
}
return true
case []any:
if typed == nil {
return false
}
for _, item := range typed {
if !isPlainJSONValue(item, depth+1) {
return false
}
}
return true
default:
return false
}
}
// Already-parsed JSON and the engine's plain exported values do not need another
// encode/decode pass. Copy containers for host-side edits, preserving the codec's
// float64 numbers and nulls. Other Go types still use the configured codec.
func cloneJSONValue(value any, depth int) (any, bool) {
if depth > 64 {
return nil, false
}
switch typed := value.(type) {
case nil, bool:
return typed, true
case string:
return typed, utf8.ValidString(typed)
case float64:
return typed, !math.IsNaN(typed) && !math.IsInf(typed, 0)
case int64:
return float64(typed), true
case int:
return float64(typed), true
case map[string]any:
if typed == nil {
return nil, true
}
cloned := make(map[string]any, len(typed))
for key, item := range typed {
if !utf8.ValidString(key) {
return nil, false
}
child, ok := cloneJSONValue(item, depth+1)
if !ok {
return nil, false
}
cloned[key] = child
}
return cloned, true
case []any:
if typed == nil {
return nil, true
}
cloned := make([]any, len(typed))
for index, item := range typed {
child, ok := cloneJSONValue(item, depth+1)
if !ok {
return nil, false
}
cloned[index] = child
}
return cloned, true
default:
return nil, false
}
}
func positiveInt(value any) int {
switch number := value.(type) {
case int64:
if number <= 0 {
return 0
}
return common.QuotaFromFloat(float64(number))
case float64:
if number <= 0 {
return 0
}
return common.QuotaFromFloat(number)
default:
return 0
}
}
var _ channel.TaskAdaptor = (*TaskAdaptor)(nil)
var _ channel.OpenAIVideoConverter = (*TaskAdaptor)(nil)
var _ channel.TaskArtifactProvider = (*TaskAdaptor)(nil)
var _ channel.TaskContentRequestProvider = (*TaskAdaptor)(nil)
var _ channel.TaskUsageFactsProvider = (*TaskAdaptor)(nil)
var _ channel.TaskValidatedBillingProvider = (*TaskAdaptor)(nil)
var _ channel.TaskValidatedUsageFactsProvider = (*TaskAdaptor)(nil)