Files
new-api/relay/plugin_protocol.go
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

1074 lines
35 KiB
Go

package relay
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"maps"
"math"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
)
const (
pluginResponseStatusInProgress = "in_progress"
pluginResponseStatusCompleted = "completed"
pluginResponseStatusFailed = "failed"
pluginResponseStatusIncomplete = "incomplete"
pluginResponseStatusQueued = "queued"
)
// PluginProtocolLimits bounds untrusted semantic output before it reaches the
// host-owned Responses state machine.
type PluginProtocolLimits struct {
MaxEventsPerTick int
MaxEventsBytes int
MaxEventBytes int
MaxEventDepth int
MaxStateBytes int
MaxStateDepth int
MaxOutputs int
MaxTotalOutputBytes int
MaxMessageBytes int
MaxMetadataValueBytes int
MaxCodeBytes int
}
func DefaultPluginProtocolLimits() PluginProtocolLimits {
return PluginProtocolLimits{
MaxEventsPerTick: 16,
MaxEventsBytes: 64 << 10,
MaxEventBytes: 32 << 10,
MaxEventDepth: 16,
MaxStateBytes: 16 << 10,
MaxStateDepth: 16,
MaxOutputs: 64,
MaxTotalOutputBytes: 1 << 20,
MaxMessageBytes: 4 << 10,
MaxMetadataValueBytes: 512,
MaxCodeBytes: 128,
}
}
func (l PluginProtocolLimits) withDefaults() PluginProtocolLimits {
defaults := DefaultPluginProtocolLimits()
if l.MaxEventsPerTick <= 0 {
l.MaxEventsPerTick = defaults.MaxEventsPerTick
}
if l.MaxEventsBytes <= 0 {
l.MaxEventsBytes = defaults.MaxEventsBytes
}
if l.MaxEventBytes <= 0 {
l.MaxEventBytes = defaults.MaxEventBytes
}
if l.MaxEventDepth <= 0 {
l.MaxEventDepth = defaults.MaxEventDepth
}
if l.MaxStateBytes <= 0 {
l.MaxStateBytes = defaults.MaxStateBytes
}
if l.MaxStateDepth <= 0 {
l.MaxStateDepth = defaults.MaxStateDepth
}
if l.MaxOutputs <= 0 {
l.MaxOutputs = defaults.MaxOutputs
}
if l.MaxTotalOutputBytes <= 0 {
l.MaxTotalOutputBytes = defaults.MaxTotalOutputBytes
}
if l.MaxMessageBytes <= 0 {
l.MaxMessageBytes = defaults.MaxMessageBytes
}
if l.MaxMetadataValueBytes <= 0 {
l.MaxMetadataValueBytes = defaults.MaxMetadataValueBytes
}
if l.MaxCodeBytes <= 0 {
l.MaxCodeBytes = defaults.MaxCodeBytes
}
return l
}
// ProtocolState distinguishes an omitted state property from an explicit JSON
// null. Value is always a validated, detached JSON value when Present is true.
type ProtocolState struct {
Present bool
Null bool
Value json.RawMessage
}
// PluginValue returns the state as a hook argument: nil when it is absent or
// null, otherwise its JSON text for the hook to receive parsed.
func (s ProtocolState) PluginValue() any {
if !s.Present || s.Null {
return nil
}
return pluginruntime.RawJSON(s.Value)
}
type ProtocolSemanticEvent struct {
Type string
Progress *float64
Message *string
Data json.RawMessage
Code *string
}
type ProtocolEventResult struct {
Events []ProtocolSemanticEvent
State ProtocolState
Done bool
}
// DecodePluginProtocolEventResult converts an exported JS value into the
// deliberately small semantic event contract. Unknown fields are rejected so
// plugins cannot smuggle protocol-owned wire fields into the response.
func DecodePluginProtocolEventResult(value any, limits PluginProtocolLimits) (ProtocolEventResult, error) {
limits = limits.withDefaults()
encoded, err := common.Marshal(value)
if err != nil {
return ProtocolEventResult{}, fmt.Errorf("protocol event result is not JSON-compatible: %w", err)
}
maxResultBytes := limits.MaxEventsBytes + limits.MaxStateBytes + 4096
if len(encoded) > maxResultBytes {
return ProtocolEventResult{}, fmt.Errorf("protocol event result exceeds %d bytes", maxResultBytes)
}
var fields map[string]json.RawMessage
if err := common.Unmarshal(encoded, &fields); err != nil || fields == nil {
return ProtocolEventResult{}, errors.New("protocol event result must be an object")
}
for name := range fields {
switch name {
case "events", "state", "done":
default:
return ProtocolEventResult{}, fmt.Errorf("protocol event result contains unknown field %q", name)
}
}
rawEvents, ok := fields["events"]
if !ok || isJSONNull(rawEvents) {
return ProtocolEventResult{}, errors.New("protocol event result events must be an array")
}
if len(rawEvents) > limits.MaxEventsBytes {
return ProtocolEventResult{}, fmt.Errorf("protocol events exceed %d bytes", limits.MaxEventsBytes)
}
var encodedEvents []json.RawMessage
if err := common.Unmarshal(rawEvents, &encodedEvents); err != nil {
return ProtocolEventResult{}, errors.New("protocol event result events must be an array")
}
if len(encodedEvents) > limits.MaxEventsPerTick {
return ProtocolEventResult{}, fmt.Errorf("protocol events exceed limit of %d", limits.MaxEventsPerTick)
}
rawDone, ok := fields["done"]
if !ok || isJSONNull(rawDone) {
return ProtocolEventResult{}, errors.New("protocol event result done must be a boolean")
}
var done bool
if err := common.Unmarshal(rawDone, &done); err != nil {
return ProtocolEventResult{}, errors.New("protocol event result done must be a boolean")
}
result := ProtocolEventResult{
Events: make([]ProtocolSemanticEvent, 0, len(encodedEvents)),
Done: done,
}
for _, rawEvent := range encodedEvents {
event, err := decodePluginSemanticEvent(rawEvent, limits)
if err != nil {
return ProtocolEventResult{}, err
}
result.Events = append(result.Events, event)
}
if rawState, exists := fields["state"]; exists {
if len(rawState) > limits.MaxStateBytes {
return ProtocolEventResult{}, fmt.Errorf("protocol state exceeds %d bytes", limits.MaxStateBytes)
}
depth, err := pluginJSONDepth(rawState)
if err != nil {
return ProtocolEventResult{}, errors.New("protocol state must be JSON-compatible")
}
if depth > limits.MaxStateDepth {
return ProtocolEventResult{}, fmt.Errorf("protocol state exceeds depth limit of %d", limits.MaxStateDepth)
}
result.State = ProtocolState{
Present: true,
Null: isJSONNull(rawState),
Value: append(json.RawMessage(nil), rawState...),
}
}
return result, nil
}
func decodePluginSemanticEvent(raw json.RawMessage, limits PluginProtocolLimits) (ProtocolSemanticEvent, error) {
if len(raw) > limits.MaxEventBytes {
return ProtocolSemanticEvent{}, fmt.Errorf("protocol event exceeds %d bytes", limits.MaxEventBytes)
}
depth, err := pluginJSONDepth(raw)
if err != nil {
return ProtocolSemanticEvent{}, errors.New("protocol event must be a JSON object")
}
if depth > limits.MaxEventDepth {
return ProtocolSemanticEvent{}, fmt.Errorf("protocol event exceeds depth limit of %d", limits.MaxEventDepth)
}
var fields map[string]json.RawMessage
if err := common.Unmarshal(raw, &fields); err != nil || fields == nil {
return ProtocolSemanticEvent{}, errors.New("protocol event must be a JSON object")
}
rawType, ok := fields["type"]
if !ok || isJSONNull(rawType) {
return ProtocolSemanticEvent{}, errors.New("protocol event type is required")
}
var eventType string
if err := common.Unmarshal(rawType, &eventType); err != nil {
return ProtocolSemanticEvent{}, errors.New("protocol event type must be a string")
}
event := ProtocolSemanticEvent{Type: eventType}
switch eventType {
case "progress":
if err := rejectUnknownProtocolFields(fields, "type", "progress", "message"); err != nil {
return ProtocolSemanticEvent{}, err
}
if rawProgress, exists := fields["progress"]; exists {
if isJSONNull(rawProgress) {
return ProtocolSemanticEvent{}, errors.New("progress event progress must be a number")
}
var progress float64
if err := common.Unmarshal(rawProgress, &progress); err != nil ||
math.IsNaN(progress) || math.IsInf(progress, 0) ||
progress < 0 || progress > 100 {
return ProtocolSemanticEvent{}, errors.New("progress event progress must be between 0 and 100")
}
event.Progress = &progress
}
if rawMessage, exists := fields["message"]; exists {
message, err := decodeBoundedProtocolString(rawMessage, "progress event message", limits.MaxMetadataValueBytes, false)
if err != nil {
return ProtocolSemanticEvent{}, err
}
event.Message = &message
}
case "output":
if err := rejectUnknownProtocolFields(fields, "type", "data"); err != nil {
return ProtocolSemanticEvent{}, err
}
rawData, exists := fields["data"]
if !exists {
return ProtocolSemanticEvent{}, errors.New("output event data is required")
}
if len(rawData) > limits.MaxEventBytes {
return ProtocolSemanticEvent{}, fmt.Errorf("output event data exceeds %d bytes", limits.MaxEventBytes)
}
event.Data = append(json.RawMessage(nil), rawData...)
case "error":
if err := rejectUnknownProtocolFields(fields, "type", "code", "message"); err != nil {
return ProtocolSemanticEvent{}, err
}
rawMessage, exists := fields["message"]
if !exists {
return ProtocolSemanticEvent{}, errors.New("error event message is required")
}
message, err := decodeBoundedProtocolString(rawMessage, "error event message", limits.MaxMessageBytes, true)
if err != nil {
return ProtocolSemanticEvent{}, err
}
event.Message = &message
if rawCode, exists := fields["code"]; exists {
code, err := decodeBoundedProtocolString(rawCode, "error event code", limits.MaxCodeBytes, false)
if err != nil {
return ProtocolSemanticEvent{}, err
}
event.Code = &code
}
default:
return ProtocolSemanticEvent{}, fmt.Errorf("unsupported protocol event type %q", eventType)
}
return event, nil
}
func rejectUnknownProtocolFields(fields map[string]json.RawMessage, allowed ...string) error {
allowedSet := make(map[string]struct{}, len(allowed))
for _, name := range allowed {
allowedSet[name] = struct{}{}
}
for name := range fields {
if _, ok := allowedSet[name]; !ok {
return fmt.Errorf("protocol event contains unknown field %q", name)
}
}
return nil
}
func decodeBoundedProtocolString(raw json.RawMessage, field string, maxBytes int, required bool) (string, error) {
if isJSONNull(raw) {
return "", fmt.Errorf("%s must be a string", field)
}
var value string
if err := common.Unmarshal(raw, &value); err != nil {
return "", fmt.Errorf("%s must be a string", field)
}
if required && strings.TrimSpace(value) == "" {
return "", fmt.Errorf("%s is required", field)
}
if len(value) > maxBytes {
return "", fmt.Errorf("%s exceeds %d bytes", field, maxBytes)
}
return value, nil
}
func pluginJSONDepth(raw json.RawMessage) (int, error) {
var value any
if err := common.Unmarshal(raw, &value); err != nil {
return 0, err
}
var depth func(any) int
depth = func(current any) int {
switch typed := current.(type) {
case []any:
maxChild := 0
for _, child := range typed {
maxChild = max(maxChild, depth(child))
}
return 1 + maxChild
case map[string]any:
maxChild := 0
for _, child := range typed {
maxChild = max(maxChild, depth(child))
}
return 1 + maxChild
default:
return 1
}
}
return depth(value), nil
}
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
// PluginResponsesMachine owns the Responses wire state for one durable task.
// It contains no IO and is intentionally independent of plugin runtimes.
type PluginResponsesMachine struct {
taskID string
responseID string
model string
createdAt int64
limits PluginProtocolLimits
nextSequence int
started bool
terminal bool
status string
metadata map[string]string
outputs []dto.PluginResponsesOutput
totalOutputBytes int
usage *dto.PluginResponsesUsage
background bool
}
func NewPluginResponsesMachine(taskID, model string, createdAt int64, limits PluginProtocolLimits) *PluginResponsesMachine {
taskID = strings.TrimSpace(taskID)
responseID := "resp_" + strings.TrimPrefix(taskID, "task_")
return &PluginResponsesMachine{
taskID: taskID,
responseID: responseID,
model: model,
createdAt: createdAt,
limits: limits.withDefaults(),
status: pluginResponseStatusInProgress,
metadata: map[string]string{
"task_id": taskID,
"task_status": pluginResponseStatusQueued,
"retrieval_path": "/v1/responses/" + responseID,
},
outputs: make([]dto.PluginResponsesOutput, 0),
}
}
func (m *PluginResponsesMachine) SetBackground(background bool) {
m.background = background
}
// PendingResponse is the host-synthesized non-terminal Responses snapshot.
// Callers must pass a non-terminal task status; completed/failed/incomplete
// inputs are mapped to in_progress so the wire status stays queued|in_progress.
func (m *PluginResponsesMachine) PendingResponse(taskStatus string) map[string]any {
status := pluginTaskStatus(taskStatus)
if status != pluginResponseStatusQueued && status != pluginResponseStatusInProgress {
status = pluginResponseStatusInProgress
}
return map[string]any{
"id": m.responseID,
"object": "response",
"created_at": m.createdAt,
"status": status,
"background": m.background,
"completed_at": nil,
"error": nil,
"incomplete_details": nil,
"model": m.model,
"output": []any{},
"usage": nil,
"metadata": map[string]string{
"task_id": m.taskID,
"task_status": status,
"retrieval_path": "/v1/responses/" + m.responseID,
},
}
}
func (m *PluginResponsesMachine) CreatedEvent() (dto.PluginResponsesStreamEvent, error) {
if m.started {
return dto.PluginResponsesStreamEvent{}, errors.New("response.created was already emitted")
}
if m.terminal {
return dto.PluginResponsesStreamEvent{}, errors.New("response is already terminal")
}
m.started = true
return m.responseEvent("response.created"), nil
}
// ApplyTick maps bounded plugin semantics onto host-owned Responses events.
// taskStatus is the current durable DB status (for example, IN_PROGRESS,
// SUCCESS, or FAILURE).
func (m *PluginResponsesMachine) ApplyTick(result ProtocolEventResult, taskStatus string) ([]dto.PluginResponsesStreamEvent, error) {
if !m.started {
return nil, errors.New("response.created must be emitted before applying events")
}
if m.terminal {
return nil, errors.New("response is already terminal")
}
if strings.EqualFold(strings.TrimSpace(taskStatus), "FAILURE") {
m.metadata["task_status"] = pluginResponseStatusFailed
return []dto.PluginResponsesStreamEvent{m.fail("server_error", "The task failed.")}, nil
}
outputTexts := make(map[int]string)
additionalBytes := 0
additionalOutputs := 0
for index, event := range result.Events {
switch event.Type {
case "progress":
if event.Progress != nil &&
(math.IsNaN(*event.Progress) || math.IsInf(*event.Progress, 0) ||
*event.Progress < 0 || *event.Progress > 100) {
return nil, errors.New("progress event progress must be between 0 and 100")
}
if event.Message != nil && len(*event.Message) > m.limits.MaxMetadataValueBytes {
return nil, fmt.Errorf("progress event message exceeds %d bytes", m.limits.MaxMetadataValueBytes)
}
case "output":
text, err := pluginOutputText(event.Data)
if err != nil {
return nil, err
}
if len(text) > m.limits.MaxEventBytes {
return nil, fmt.Errorf("output event data exceeds %d bytes", m.limits.MaxEventBytes)
}
outputTexts[index] = text
additionalBytes += len(text)
additionalOutputs++
case "error":
if event.Message == nil || strings.TrimSpace(*event.Message) == "" {
return nil, errors.New("error event message is required")
}
default:
return nil, fmt.Errorf("unsupported protocol event type %q", event.Type)
}
}
if len(m.outputs)+additionalOutputs > m.limits.MaxOutputs {
return nil, fmt.Errorf("response outputs exceed limit of %d", m.limits.MaxOutputs)
}
if m.totalOutputBytes+additionalBytes > m.limits.MaxTotalOutputBytes {
return nil, fmt.Errorf("response output exceeds cumulative limit of %d bytes", m.limits.MaxTotalOutputBytes)
}
m.metadata["task_status"] = pluginTaskStatus(taskStatus)
events := make([]dto.PluginResponsesStreamEvent, 0, len(result.Events)*2+1)
for index, semantic := range result.Events {
switch semantic.Type {
case "progress":
if semantic.Progress != nil {
m.metadata["task_progress"] = strconv.FormatFloat(*semantic.Progress, 'f', -1, 64)
}
if semantic.Message != nil {
m.metadata["task_message"] = *semantic.Message
}
if len(m.outputs) == 0 {
events = append(events, m.progressEvent())
}
case "output":
events = append(events, m.appendOutput(outputTexts[index])...)
case "error":
events = append(events, m.fail(
"server_error",
"The task failed.",
))
return events, nil
}
}
switch strings.ToUpper(strings.TrimSpace(taskStatus)) {
case "SUCCESS":
events = append(events, m.complete())
case "FAILURE":
events = append(events, m.fail("server_error", "The task failed."))
default:
if result.Done {
events = append(events, m.incomplete())
}
}
return events, nil
}
// FailureEvent terminates an already-started stream after a host observation,
// hook, or validation failure. It never accepts a detail string, preventing
// upstream and plugin internals from reaching clients.
func (m *PluginResponsesMachine) FailureEvent(taskStatus ...string) (dto.PluginResponsesStreamEvent, error) {
if !m.started {
return dto.PluginResponsesStreamEvent{}, errors.New("response.created must be emitted before response.failed")
}
if m.terminal {
return dto.PluginResponsesStreamEvent{}, errors.New("response is already terminal")
}
m.setPersistedTaskStatus(taskStatus)
return m.fail("server_error", "The task could not be observed."), nil
}
func (m *PluginResponsesMachine) TimeoutEvent(taskStatus ...string) (dto.PluginResponsesStreamEvent, error) {
if !m.started {
return dto.PluginResponsesStreamEvent{}, errors.New("response.created must be emitted before response.incomplete")
}
if m.terminal {
return dto.PluginResponsesStreamEvent{}, errors.New("response is already terminal")
}
m.setPersistedTaskStatus(taskStatus)
return m.incomplete(), nil
}
// FinalResponse validates a plugin-authored complete Responses object and
// overwrites every host-owned identity and lifecycle field. Unknown
// protocol fields are retained so the unreleased v1 Record contract can
// represent response features beyond output_text.
func (m *PluginResponsesMachine) FinalResponse(payload any, taskStatus string) (map[string]any, error) {
if m.started || m.terminal {
return nil, errors.New("response state machine has already started")
}
switch strings.ToUpper(strings.TrimSpace(taskStatus)) {
case "SUCCESS":
response, err := m.canonicalFinalResponse(payload)
if err != nil {
return nil, err
}
m.status = pluginResponseStatusCompleted
m.metadata["task_status"] = pluginResponseStatusCompleted
response["id"] = m.responseID
response["object"] = "response"
response["created_at"] = m.createdAt
response["status"] = pluginResponseStatusCompleted
response["error"] = nil
response["incomplete_details"] = nil
response["model"] = m.model
response["metadata"] = m.finalMetadata(response["metadata"])
response["usage"] = zeroPluginResponsesUsage()
delete(response, "sequence_number")
m.terminal = true
return response, nil
case "FAILURE":
m.status = pluginResponseStatusFailed
m.metadata["task_status"] = pluginResponseStatusFailed
default:
return nil, errors.New("final response requires a terminal task")
}
m.terminal = true
response, err := pluginResponseMap(m.responseSnapshot(&dto.PluginResponsesError{
Code: "server_error",
Message: "The task failed.",
}))
if err != nil {
return nil, err
}
return response, nil
}
// FinalFromEvents synthesizes the retrieval Response for stream-only plugins
// from one renderEvents call at terminal task status. Synthesis runs on a
// scratch machine so a hook failure leaves the receiver untouched and the
// caller's failure-envelope path (which requires an unstarted machine) stays valid.
func (m *PluginResponsesMachine) FinalFromEvents(result ProtocolEventResult, taskStatus string) (map[string]any, error) {
if m.started || m.terminal {
return nil, errors.New("response state machine has already started")
}
scratch := NewPluginResponsesMachine(m.taskID, m.model, m.createdAt, m.limits)
scratch.background = m.background
scratch.started = true
if _, err := scratch.ApplyTick(result, taskStatus); err != nil {
return nil, err
}
if !scratch.terminal {
return nil, errors.New("renderEvents did not terminate at terminal task status")
}
if scratch.status != pluginResponseStatusCompleted {
return nil, errors.New("renderEvents reported failure at terminal task status")
}
*m = *scratch
return pluginResponseMap(m.responseSnapshot(nil))
}
// FailureResponse returns a sanitized non-stream failure for host-side
// protocol errors.
func (m *PluginResponsesMachine) FailureResponse(taskStatus ...string) (*dto.PluginResponsesResponse, error) {
if m.started || m.terminal {
return nil, errors.New("response state machine has already started")
}
m.status = pluginResponseStatusFailed
m.setPersistedTaskStatus(taskStatus)
m.terminal = true
return m.responseSnapshot(&dto.PluginResponsesError{
Code: "server_error",
Message: "The task could not be observed.",
}), nil
}
// TimeoutResponse is the documented non-stream polling timeout shape. Unlike
// a live stream timeout, its top-level state remains queued so clients know to
// use retrieval_path rather than treating observation timeout as task failure.
func (m *PluginResponsesMachine) TimeoutResponse(taskStatus ...string) (*dto.PluginResponsesResponse, error) {
if m.started || m.terminal {
return nil, errors.New("response state machine has already started")
}
m.status = pluginResponseStatusQueued
lastStatus := ""
if len(taskStatus) > 0 {
lastStatus = taskStatus[0]
}
persistedStatus := pluginTaskStatus(lastStatus)
if persistedStatus == "" {
persistedStatus = pluginResponseStatusQueued
}
m.metadata["task_status"] = persistedStatus
m.terminal = true
return m.responseSnapshot(nil), nil
}
func (m *PluginResponsesMachine) canonicalFinalResponse(payload any) (map[string]any, error) {
encoded, err := common.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("final response is not JSON-compatible: %w", err)
}
if len(encoded) > m.limits.MaxTotalOutputBytes+(64<<10) {
return nil, fmt.Errorf("final response exceeds %d bytes", m.limits.MaxTotalOutputBytes+(64<<10))
}
depth, err := pluginJSONDepth(encoded)
if err != nil || depth > m.limits.MaxEventDepth {
return nil, fmt.Errorf("final response exceeds depth limit of %d", m.limits.MaxEventDepth)
}
var response map[string]any
if err = common.Unmarshal(encoded, &response); err != nil || response == nil {
return nil, errors.New("final response must be an object")
}
rawOutput, exists := response["output"]
output := []any{}
if exists {
var ok bool
output, ok = rawOutput.([]any)
if !ok {
return nil, errors.New("final response output must be an array")
}
}
if len(output) > m.limits.MaxOutputs {
return nil, fmt.Errorf("response outputs exceed limit of %d", m.limits.MaxOutputs)
}
for outputIndex, rawItem := range output {
item, ok := rawItem.(map[string]any)
if !ok {
return nil, errors.New("final response output items must be objects")
}
itemType, ok := item["type"].(string)
if !ok || itemType != "message" {
return nil, errors.New("final response output items must be message objects")
}
role, ok := item["role"].(string)
if !ok || role != "assistant" {
return nil, errors.New("final response message role must be assistant")
}
item["id"] = fmt.Sprintf("item_%s_%d", m.taskID, outputIndex)
item["status"] = pluginResponseStatusCompleted
rawContent, hasContent := item["content"]
if !hasContent {
return nil, errors.New("final response message content must be an array")
}
content, ok := rawContent.([]any)
if !ok || len(content) > m.limits.MaxOutputs {
return nil, fmt.Errorf("final response output content must contain at most %d objects", m.limits.MaxOutputs)
}
for contentIndex, rawPart := range content {
part, ok := rawPart.(map[string]any)
if !ok {
return nil, errors.New("final response output content parts must be objects")
}
partType, ok := part["type"].(string)
if !ok {
return nil, errors.New("final response content part type is required")
}
switch partType {
case "output_text":
if _, ok = part["text"].(string); !ok {
return nil, errors.New("final response output_text text must be a string")
}
if _, ok = part["annotations"].([]any); !ok {
return nil, errors.New("final response output_text annotations must be an array")
}
if _, ok = part["logprobs"].([]any); !ok {
return nil, errors.New("final response output_text logprobs must be an array")
}
case "refusal":
if _, ok = part["refusal"].(string); !ok {
return nil, errors.New("final response refusal must be a string")
}
default:
return nil, fmt.Errorf("unsupported final response content part type %q", partType)
}
part["id"] = fmt.Sprintf("content_%s_%d_%d", m.taskID, outputIndex, contentIndex)
}
}
encodedOutput, err := common.Marshal(output)
if err != nil || len(encodedOutput) > m.limits.MaxTotalOutputBytes {
return nil, fmt.Errorf("final response output exceeds %d bytes", m.limits.MaxTotalOutputBytes)
}
response["output"] = output
if err = normalizeFinalResponseDefaults(response); err != nil {
return nil, err
}
return response, nil
}
func normalizeFinalResponseDefaults(response map[string]any) error {
if instructions, exists := response["instructions"]; exists {
switch instructions.(type) {
case nil, string, []any:
default:
return errors.New("final response instructions must be null, a string, or an array")
}
} else {
response["instructions"] = nil
}
if parallel, exists := response["parallel_tool_calls"]; exists {
if _, ok := parallel.(bool); !ok {
return errors.New("final response parallel_tool_calls must be a boolean")
}
} else {
response["parallel_tool_calls"] = true
}
if err := normalizeFinalResponseNumber(response, "temperature", 1, 0, 2); err != nil {
return err
}
if toolChoice, exists := response["tool_choice"]; exists {
switch toolChoice.(type) {
case string, map[string]any:
default:
return errors.New("final response tool_choice must be a string or object")
}
} else {
response["tool_choice"] = "auto"
}
if tools, exists := response["tools"]; exists {
if _, ok := tools.([]any); !ok {
return errors.New("final response tools must be an array")
}
} else {
response["tools"] = []any{}
}
return normalizeFinalResponseNumber(response, "top_p", 1, 0, 1)
}
func normalizeFinalResponseNumber(
response map[string]any,
field string,
defaultValue float64,
minimum float64,
maximum float64,
) error {
value, exists := response[field]
if !exists {
response[field] = defaultValue
return nil
}
number, ok := value.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < minimum || number > maximum {
return fmt.Errorf("final response %s must be between %g and %g", field, minimum, maximum)
}
return nil
}
func (m *PluginResponsesMachine) finalMetadata(pluginValue any) map[string]string {
metadata := make(map[string]string)
if pluginMetadata, ok := pluginValue.(map[string]any); ok {
keys := make([]string, 0, len(pluginMetadata))
for key := range pluginMetadata {
keys = append(keys, key)
}
sort.Strings(keys)
pluginLimit := max(0, 16-len(m.metadata))
for _, key := range keys {
if len(metadata) >= pluginLimit {
break
}
if _, hostOwned := m.metadata[key]; hostOwned {
continue
}
rawValue := pluginMetadata[key]
value, stringOK := rawValue.(string)
if !stringOK || len(key) > 64 || len(value) > m.limits.MaxMetadataValueBytes {
continue
}
metadata[key] = value
}
}
maps.Copy(metadata, m.metadata)
return metadata
}
func pluginResponseMap(response *dto.PluginResponsesResponse) (map[string]any, error) {
encoded, err := common.Marshal(response)
if err != nil {
return nil, err
}
var value map[string]any
if err = common.Unmarshal(encoded, &value); err != nil {
return nil, err
}
return value, nil
}
func (m *PluginResponsesMachine) appendOutput(text string) []dto.PluginResponsesStreamEvent {
outputIndex := len(m.outputs)
itemID := fmt.Sprintf("msg_%s_%d", m.taskID, outputIndex)
contentID := fmt.Sprintf("content_%s_%d", m.taskID, outputIndex)
emptyLogprobs := []any{}
addedItem := dto.PluginResponsesOutput{
ID: itemID,
Type: "message",
Status: pluginResponseStatusInProgress,
Role: "assistant",
Content: []dto.PluginResponsesContent{},
}
partAdded := dto.PluginResponsesContent{
ID: contentID,
Type: "output_text",
Text: "",
Annotations: []any{},
Logprobs: []any{},
}
completedItem := m.newCompletedOutputWithIDs(itemID, contentID, text)
m.outputs = append(m.outputs, completedItem)
m.totalOutputBytes += len(text)
return []dto.PluginResponsesStreamEvent{
m.event(dto.PluginResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: intPointer(outputIndex),
Item: &addedItem,
}),
m.event(dto.PluginResponsesStreamEvent{
Type: "response.content_part.added",
OutputIndex: intPointer(outputIndex),
ContentIndex: intPointer(0),
ItemID: itemID,
Part: &partAdded,
}),
m.event(dto.PluginResponsesStreamEvent{
Type: "response.output_text.delta",
OutputIndex: intPointer(outputIndex),
ContentIndex: intPointer(0),
ItemID: itemID,
Delta: &text,
Logprobs: &emptyLogprobs,
}),
m.event(dto.PluginResponsesStreamEvent{
Type: "response.output_text.done",
OutputIndex: intPointer(outputIndex),
ContentIndex: intPointer(0),
ItemID: itemID,
Text: &text,
Logprobs: &emptyLogprobs,
}),
m.event(dto.PluginResponsesStreamEvent{
Type: "response.content_part.done",
OutputIndex: intPointer(outputIndex),
ContentIndex: intPointer(0),
ItemID: itemID,
Part: &completedItem.Content[0],
}),
m.event(dto.PluginResponsesStreamEvent{
Type: "response.output_item.done",
OutputIndex: intPointer(outputIndex),
Item: &completedItem,
}),
}
}
func (m *PluginResponsesMachine) newCompletedOutputWithIDs(itemID, contentID, text string) dto.PluginResponsesOutput {
return dto.PluginResponsesOutput{
ID: itemID,
Type: "message",
Status: pluginResponseStatusCompleted,
Role: "assistant",
Content: []dto.PluginResponsesContent{
{
ID: contentID,
Type: "output_text",
Text: text,
Annotations: []any{},
Logprobs: []any{},
},
},
}
}
func (m *PluginResponsesMachine) complete() dto.PluginResponsesStreamEvent {
m.status = pluginResponseStatusCompleted
m.metadata["task_status"] = pluginResponseStatusCompleted
m.usage = zeroPluginResponsesUsage()
m.terminal = true
return m.responseEvent("response.completed")
}
func (m *PluginResponsesMachine) incomplete() dto.PluginResponsesStreamEvent {
m.status = pluginResponseStatusIncomplete
m.usage = zeroPluginResponsesUsage()
m.terminal = true
return m.responseEvent("response.incomplete")
}
func (m *PluginResponsesMachine) fail(code, message string) dto.PluginResponsesStreamEvent {
m.status = pluginResponseStatusFailed
m.terminal = true
return m.event(dto.PluginResponsesStreamEvent{
Type: "response.failed",
Response: m.responseSnapshot(&dto.PluginResponsesError{
Code: code,
Message: message,
}),
})
}
func (m *PluginResponsesMachine) responseEvent(eventType string) dto.PluginResponsesStreamEvent {
return m.event(dto.PluginResponsesStreamEvent{
Type: eventType,
Response: m.responseSnapshot(nil),
})
}
func (m *PluginResponsesMachine) progressEvent() dto.PluginResponsesStreamEvent {
response := m.responseSnapshot(nil)
// Progress events carry task metadata, while output content is already
// represented by its own item/content events and the eventual terminal
// snapshot. Keeping this list empty prevents repeated 1 MiB snapshots.
response.Output = []dto.PluginResponsesOutput{}
return m.event(dto.PluginResponsesStreamEvent{
Type: "response.in_progress",
Response: response,
})
}
func (m *PluginResponsesMachine) setPersistedTaskStatus(taskStatus []string) {
if len(taskStatus) == 0 {
return
}
status := pluginTaskStatus(taskStatus[0])
if status != "" {
m.metadata["task_status"] = status
}
}
func (m *PluginResponsesMachine) event(event dto.PluginResponsesStreamEvent) dto.PluginResponsesStreamEvent {
event.SequenceNumber = m.nextSequence
m.nextSequence++
return event
}
func (m *PluginResponsesMachine) responseSnapshot(responseError *dto.PluginResponsesError) *dto.PluginResponsesResponse {
metadata := make(map[string]string, len(m.metadata))
maps.Copy(metadata, m.metadata)
outputs := make([]dto.PluginResponsesOutput, len(m.outputs))
for index, output := range m.outputs {
outputs[index] = output
outputs[index].Content = append([]dto.PluginResponsesContent(nil), output.Content...)
}
return &dto.PluginResponsesResponse{
ID: m.responseID,
Object: "response",
CreatedAt: m.createdAt,
Status: m.status,
Error: responseError,
IncompleteDetails: nil,
Instructions: nil,
Model: m.model,
Output: outputs,
ParallelToolCalls: true,
Temperature: 1,
ToolChoice: "auto",
Tools: []any{},
TopP: 1,
Metadata: metadata,
Usage: m.usage,
}
}
func zeroPluginResponsesUsage() *dto.PluginResponsesUsage {
return &dto.PluginResponsesUsage{}
}
func pluginOutputText(raw json.RawMessage) (string, error) {
if len(raw) == 0 {
return "", errors.New("output event data is required")
}
if common.GetJsonType(raw) == "string" {
var text string
if err := common.Unmarshal(raw, &text); err != nil {
return "", errors.New("output event data must be JSON-compatible")
}
return text, nil
}
var value any
if err := common.Unmarshal(raw, &value); err != nil {
return "", errors.New("output event data must be JSON-compatible")
}
encoded, err := common.Marshal(value)
if err != nil {
return "", errors.New("output event data must be JSON-compatible")
}
return string(encoded), nil
}
func pluginTaskStatus(taskStatus string) string {
switch strings.ToUpper(strings.TrimSpace(taskStatus)) {
case "SUCCESS":
return pluginResponseStatusCompleted
case "FAILURE":
return pluginResponseStatusFailed
case "IN_PROGRESS":
return pluginResponseStatusInProgress
case "NOT_START", "SUBMITTED", "QUEUED", "UNKNOWN", "":
return pluginResponseStatusQueued
default:
return pluginResponseStatusQueued
}
}
func intPointer(value int) *int {
return &value
}