Files
LocalAI/core/http/endpoints/openresponses/websocket.go
T
Stefan Walczandlocalai-org-maint-bot d768f1f973 feat(pii): apply PII middleware to the Open Responses API (#12708)
* feat(pii): apply PII middleware to the Open Responses API

The per-model PII filter ran on /v1/chat/completions, /v1/completions,
/v1/messages and the Ollama routes, but not on POST /v1/responses. A
model whose policy blocks or masks a secret on the chat route served the
same content unfiltered when it was sent through the Responses API.

Add a piiadapter.OpenResponses adapter and wire pii.RequestMiddleware
into the POST /v1/responses and /responses chain, after the model config
is resolved and merged and before the handler converts input items into
prompt messages. The adapter covers instructions, input as a string, and
in an input item array the text of message and reasoning items (string
content or input_text/output_text parts, including typeless
{role, content} items), function_call arguments and function_call_output
output; structured tool output is scanned as the JSON the handler
forwards. Image, file, audio and video parts are left untouched.

The Responses WebSocket mode (GET /v1/responses) is not HTTP middleware
and cannot filter, so a response.create for a model with PII filtering
enabled is now refused with pii_unsupported_transport instead of being
served unfiltered. The docs list the new route and this limit.

Tests: adapter specs for every covered field, each with a clean-text
counter-check; a route spec driving the real middleware chain with an
in-process pattern detector (block on string input, content part,
instructions and tool output; mask; clean input passes unchanged); and
WebSocket specs proving the refusal and that models without a PII
policy are still served.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* fix(pii): scan complete Responses tool output arrays

The Responses handler forwards non-string tool output as JSON. Scan
that full encoding so string arrays and extra fields cannot bypass PII
filtering. Cover array scans, redaction, clean values and route blocking.

Document that stored history is resolved after request filtering.

Assisted-by: Codex:gpt-6
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* feat(pii): report unscanned media on the Open Responses route

Extend block_unscanned_media to /v1/responses: input_image, input_file
and input_audio parts inside message items, and the same types as bare
input items, are reported as unscanned content. Tool output stays
allowed, as it is scanned as JSON text.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

---------

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-10-19 23:39:56 +02:00

666 lines
23 KiB
Go

package openresponses
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
const (
wsMaxMessageSize = 10 * 1024 * 1024 // 10MB
wsConnectionLimit = 60 * time.Minute
wsMaxConnectionLocalResponses = 128
wsMaxConnectionLocalBytes = 64 << 20 // 64 MiB
)
var errWSStreamEndedWithoutTerminal = errors.New("response stream ended without a terminal event")
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// lockedConn wraps a websocket connection with a mutex for safe concurrent writes
type lockedConn struct {
*websocket.Conn
sync.Mutex
}
type wsEventWriter interface {
writeJSON(any) error
writeTerminalJSON(any, func()) error
}
func (lc *lockedConn) writeJSON(v any) error {
lc.Lock()
defer lc.Unlock()
return lc.Conn.WriteJSON(v)
}
// writeTerminalJSON atomically hands the connection to the next response: the
// in-flight guard is released while the write lock is held, so a newly accepted
// response cannot write an event ahead of this terminal event.
func (lc *lockedConn) writeTerminalJSON(v any, release func()) error {
lc.Lock()
defer lc.Unlock()
release()
return lc.Conn.WriteJSON(v)
}
// WebSocketEndpoint handles WebSocket mode for the Responses API.
// Clients connect via ws://<host>:<port>/v1/responses and send response.create messages.
// Events are streamed back over the WebSocket connection instead of SSE.
func WebSocketEndpoint(application *application.Application) echo.HandlerFunc {
cl := application.ModelConfigLoader()
ml := application.ModelLoader()
evaluator := application.TemplatesEvaluator()
appConfig := application.ApplicationConfig()
piiPolicy := application.PIIPolicyResolver()
return func(c echo.Context) error {
owner := ownerFromContext(c)
ws, err := wsUpgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
defer ws.Close()
ws.SetReadLimit(wsMaxMessageSize)
// Set absolute deadline so blocking ReadMessage unblocks after the limit
deadline := time.Now().Add(wsConnectionLimit)
ws.SetReadDeadline(deadline)
ws.SetWriteDeadline(deadline)
conn := &lockedConn{Conn: ws}
// Context for cancelling in-flight work when the connection closes
connCtx, connCancel := context.WithDeadline(context.Background(), deadline)
defer connCancel()
xlog.Debug("WebSocket Responses connection established", "address", ws.RemoteAddr().String())
handleWebSocketConnection(connCtx, conn, owner, cl, ml, evaluator, appConfig, piiPolicy)
return nil
}
}
// handleWebSocketConnection runs the read loop for a single WebSocket connection.
func handleWebSocketConnection(connCtx context.Context, conn *lockedConn, owner string, cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, piiPolicy pii.PolicyResolver) {
// Responses created with store=false remain available only to this WebSocket
// connection, matching the Responses WebSocket continuation contract without
// leaking zero-data-retention state into the process-wide response store.
connectionStore := NewResponseStore(0)
defer func() {
if err := connectionStore.Close(); err != nil {
xlog.Warn("WebSocket Responses: failed to close connection-local response store", "error", err)
}
}()
// Track in-flight response to enforce one-at-a-time
var inflight sync.Mutex
// Read loop
for {
select {
case <-connCtx.Done():
sendWSError(conn, "websocket_connection_limit_reached", "Connection exceeded maximum duration", "")
return
default:
}
_, msgBytes, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
xlog.Debug("WebSocket Responses read error", "error", err)
}
return
}
// Parse the envelope to determine message type
var envelope struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msgBytes, &envelope); err != nil {
sendWSError(conn, "invalid_request", "invalid JSON message", "")
continue
}
if envelope.Type != "response.create" {
sendWSError(conn, "invalid_request", fmt.Sprintf("unsupported message type: %s", envelope.Type), "type")
continue
}
// Parse the full request
var wsMsg schema.ORWebSocketMessage
if err := json.Unmarshal(msgBytes, &wsMsg); err != nil {
sendWSError(conn, "invalid_request", fmt.Sprintf("failed to parse request: %v", err), "")
continue
}
// Enforce one in-flight response at a time (non-blocking check)
if !inflight.TryLock() {
sendWSError(conn, "invalid_request", "a response is already in progress on this connection", "")
continue
}
shouldStore := wsMsg.Store == nil || *wsMsg.Store
if !shouldStore && !connectionStoreCanAccept(connectionStore, len(msgBytes), wsMaxConnectionLocalResponses, wsMaxConnectionLocalBytes) {
inflight.Unlock()
sendWSErrorEvent(conn, "connection_store_limit_reached", "connection-local response history limit reached; reconnect to start a new local history", "store")
continue
}
go func() {
var releaseOnce sync.Once
release := func() {
releaseOnce.Do(inflight.Unlock)
}
defer release()
handleWSResponseCreate(connCtx, conn, connectionStore, release, owner, wsMsg.Generate, &wsMsg.OpenResponsesRequest, cl, ml, evaluator, appConfig, piiPolicy)
}()
}
}
// handleWSResponseCreate processes a single response.create message and streams events over WebSocket.
// It reuses the existing background stream infrastructure: the request is processed via
// handleBackgroundStream which buffers events into the store, and a forwarder goroutine
// reads those events and sends them over the WebSocket.
func handleWSResponseCreate(connCtx context.Context, conn *lockedConn, connectionStore *ResponseStore, release func(), owner string, generate *bool, input *schema.OpenResponsesRequest, cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, piiPolicy pii.PolicyResolver) {
createdAt := time.Now().Unix()
responseID := fmt.Sprintf("resp_%s", uuid.New().String())
fail := func(errType, message, param string) {
sendWSErrorAndRelease(conn, release, errType, message, param)
}
failEvent := func(code, message, param string) {
sendWSErrorEventAndRelease(conn, release, code, message, param)
}
if input.Model == "" {
fail("invalid_request", "model is required", "model")
return
}
// Resolve model configuration (same logic as middleware.SetModelAndConfig)
cfg, err := cl.LoadModelConfigFileByNameDefaultOptions(input.Model, appConfig)
if err != nil {
xlog.Warn("WebSocket Responses: model config not found", "model", input.Model, "error", err)
fail("invalid_request", fmt.Sprintf("model not found: %s", input.Model), "model")
return
}
if cfg.Model == "" {
cfg.Model = input.Model
}
// Merge request params into config (same as mergeOpenResponsesRequestAndModelConfig)
if err := middleware.MergeOpenResponsesConfig(cfg, input); err != nil {
fail("invalid_request", fmt.Sprintf("invalid configuration: %v", err), "")
return
}
// The PII request middleware is HTTP middleware and never sees messages
// arriving over this socket. Rather than serve a PII-enabled model
// unfiltered (the same content would be blocked or masked on
// POST /v1/responses), refuse it here: fail closed, as the middleware
// does when its detector is unavailable.
if piiPolicy != nil {
if enabled, detectors := piiPolicy(cfg); enabled && len(detectors) > 0 {
xlog.Warn("WebSocket Responses: refusing PII-enabled model, PII filtering is not applied on the WebSocket transport", "model", input.Model)
fail("pii_unsupported_transport", "PII filtering is enabled for this model and is not available over the Responses WebSocket; use POST /v1/responses", "model")
return
}
}
// Set up context with cancellation tied to connection lifetime
reqCtx, reqCancel := context.WithCancel(connCtx)
defer reqCancel()
input.Context = reqCtx
input.Cancel = reqCancel
globalStore := GetGlobalStore()
if appConfig.OpenResponsesStoreTTL > 0 {
globalStore.SetTTL(appConfig.OpenResponsesStoreTTL)
}
shouldStore := true
if input.Store != nil && !*input.Store {
shouldStore = false
}
store := globalStore
if !shouldStore {
store = connectionStore
}
// Resolve and authorize the complete continuation chain before prewarm or
// generation. A stored response cannot depend on connection-local history,
// because that private ancestor disappears when this socket disconnects.
var messages []schema.Message
if input.PreviousResponseID != "" {
previousMessages, usedConnectionLocal, err := resolvePreviousResponseMessagesFromSources(
[]previousResponseStoreSource{
{store: connectionStore, connectionLocal: true},
{store: globalStore},
},
input.PreviousResponseID,
cfg,
owner,
)
if err != nil {
if notFound, ok := err.(*previousResponseNotFoundError); ok {
failEvent("previous_response_not_found", notFound.Error(), "previous_response_id")
return
}
fail("invalid_request", err.Error(), "")
return
}
if shouldStore && usedConnectionLocal {
failEvent("invalid_store_transition", "store=true cannot persist a response that depends on connection-local store=false history; use store=false or start a new stored chain", "store")
return
}
messages = previousMessages
}
// Codex uses generate=false to prewarm the Responses WebSocket with the
// exact request it may send next. Persist the request and return a terminal
// response ID, but do not build a prompt or invoke the model backend.
if generate != nil && !*generate {
responseCreated := buildORResponse(responseID, createdAt, nil, schema.ORStatusInProgress, input, []schema.ORItemField{}, nil, shouldStore)
store.StoreBackgroundOwned(responseID, input, responseCreated, reqCancel, true, owner)
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.created",
SequenceNumber: 0,
Response: responseCreated,
})
now := time.Now().Unix()
responseCompleted := buildORResponse(responseID, createdAt, &now, schema.ORStatusCompleted, input, []schema.ORItemField{}, nil, shouldStore)
if err := store.UpdateResponse(responseID, responseCompleted); err != nil {
fail("server_error", fmt.Sprintf("failed to complete prewarm response: %v", err), "")
if !shouldStore {
store.Delete(responseID)
}
return
}
bufferEvent(store, responseID, &schema.ORStreamEvent{
Type: "response.completed",
SequenceNumber: 1,
Response: responseCompleted,
})
processDone := make(chan struct{})
close(processDone)
lastSequence, forwardErr := forwardEvents(reqCtx, conn, store, responseID, processDone, release)
if !shouldStore {
if err := store.ClearEvents(responseID); err != nil {
xlog.Warn("WebSocket Responses: failed to clear local prewarm events", "response_id", responseID, "error", err)
}
}
if forwardErr != nil && connCtx.Err() == nil {
if err := writeWSForwardingFailure(conn, release, responseID, createdAt, lastSequence, input, shouldStore, forwardErr); err != nil {
xlog.Debug("WebSocket Responses: failed to write forwarding failure", "response_id", responseID, "error", err)
}
}
return
}
// Convert current input to messages
newMessages, err := convertORInputToMessages(input.Input, cfg)
if err != nil {
fail("invalid_request", fmt.Sprintf("failed to parse input: %v", err), "")
return
}
messages = append(messages, newMessages...)
if input.Instructions != "" {
messages = append([]schema.Message{{Role: "system", StringContent: input.Instructions}}, messages...)
}
// Handle tools
var funcs functions.Functions
var shouldUseFn bool
if len(input.Tools) > 0 {
funcs, shouldUseFn = convertORToolsToFunctions(input, cfg)
}
// Create OpenAI-compatible request
openAIReq := &schema.OpenAIRequest{
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: input.Model},
Temperature: input.Temperature,
TopP: input.TopP,
Maxtokens: input.MaxOutputTokens,
},
Messages: messages,
Stream: true, // WebSocket mode always streams
Context: reqCtx,
Cancel: reqCancel,
Functions: funcs,
}
if input.TextFormat != nil {
openAIReq.ResponseFormat = convertTextFormatToResponseFormat(input.TextFormat)
}
// Generate grammar for function calling
if shouldUseFn && !cfg.FunctionsConfig.GrammarConfig.NoGrammar {
noActionName := "answer"
noActionDescription := "use this action to answer without performing any action"
if cfg.FunctionsConfig.NoActionFunctionName != "" {
noActionName = cfg.FunctionsConfig.NoActionFunctionName
}
if cfg.FunctionsConfig.NoActionDescriptionName != "" {
noActionDescription = cfg.FunctionsConfig.NoActionDescriptionName
}
noActionGrammar := functions.Function{
Name: noActionName,
Description: noActionDescription,
Parameters: map[string]any{
"properties": map[string]any{
"message": map[string]any{
"type": "string",
"description": "The message to reply the user with",
},
},
},
}
funcsWithNoAction := make(functions.Functions, len(funcs))
copy(funcsWithNoAction, funcs)
if !cfg.FunctionsConfig.DisableNoAction {
funcsWithNoAction = append(funcsWithNoAction, noActionGrammar)
}
if cfg.FunctionToCall() != "" {
funcsWithNoAction = funcsWithNoAction.Select(cfg.FunctionToCall())
}
jsStruct := cfg.FunctionsConfig.ToJSONStructure(funcsWithNoAction)
g, err := jsStruct.Grammar(cfg.FunctionsConfig.GrammarOptions()...)
if err == nil {
cfg.Grammar = g
} else {
xlog.Error("WebSocket Responses: failed generating grammar", "error", err)
}
}
// Merge contiguous assistant messages
openAIReq.Messages = mergeContiguousAssistantMessages(openAIReq.Messages)
predInput := evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, funcs, shouldUseFn)
// Use the background stream infrastructure: store the request as a background task,
// process it via handleBackgroundStream, and forward buffered events over WebSocket.
queuedResponse := buildORResponse(responseID, createdAt, nil, schema.ORStatusQueued, input, []schema.ORItemField{}, nil, shouldStore)
store.StoreBackgroundOwned(responseID, input, queuedResponse, reqCancel, true, owner)
// Start processing in a goroutine
processDone := make(chan struct{})
go func() {
defer close(processDone)
store.UpdateStatus(responseID, schema.ORStatusInProgress, nil)
finalResponse, bgErr := handleBackgroundStream(reqCtx, store, responseID, createdAt, input, cfg, ml, cl, appConfig, predInput, openAIReq, funcs, shouldUseFn, shouldStore, nil, nil)
if bgErr != nil {
xlog.Error("WebSocket Responses: processing failed", "response_id", responseID, "error", bgErr)
now := time.Now().Unix()
store.UpdateStatus(responseID, schema.ORStatusFailed, &now)
// Allocate the failure event after the producer's last event. Using a
// default sequence of zero can make clients miss the terminal event.
failedResponse := buildORResponse(responseID, createdAt, &now, schema.ORStatusFailed, input, []schema.ORItemField{}, nil, shouldStore)
failureEvent := &schema.ORStreamEvent{
Type: "response.failed",
Response: failedResponse,
Error: &schema.ORErrorPayload{
Type: "server_error",
Message: bgErr.Error(),
},
}
normalizeORStreamEvent(failureEvent)
if err := store.AppendEventNext(responseID, failureEvent); err != nil {
xlog.Error("WebSocket Responses: failed to buffer failure event", "response_id", responseID, "error", err)
}
return
}
if finalResponse != nil {
store.UpdateResponse(responseID, finalResponse)
}
}()
// Forward events from the store to the WebSocket connection
lastSequence, forwardErr := forwardEvents(reqCtx, conn, store, responseID, processDone, release)
if forwardErr != nil {
reqCancel()
select {
case <-processDone:
case <-connCtx.Done():
}
}
if !shouldStore {
if err := store.ClearEvents(responseID); err != nil {
xlog.Warn("WebSocket Responses: failed to clear local response events", "response_id", responseID, "error", err)
}
}
if forwardErr != nil && connCtx.Err() == nil {
xlog.Error("WebSocket Responses: event forwarding failed", "response_id", responseID, "error", forwardErr)
if err := writeWSForwardingFailure(conn, release, responseID, createdAt, lastSequence, input, shouldStore, forwardErr); err != nil {
xlog.Debug("WebSocket Responses: failed to write forwarding failure", "response_id", responseID, "error", err)
}
}
}
// forwardEvents subscribes to events for a response and sends them over the WebSocket.
// This mirrors handleStreamResume but writes JSON to WebSocket instead of SSE.
// The returned sequence is the last event successfully delivered to the client.
func forwardEvents(ctx context.Context, conn wsEventWriter, store *ResponseStore, responseID string, done <-chan struct{}, release func()) (int, error) {
eventsChan, err := store.GetEventsChan(responseID)
if err != nil {
return -1, err
}
writeEvent := func(parsed *schema.ORStreamEvent) (terminal bool, err error) {
switch parsed.Type {
case "response.completed", "response.failed", "error":
// A terminal event is the protocol boundary for accepting the next
// response.create. Wait until processing has fully stopped, then
// release the in-flight guard before making that event visible.
select {
case <-ctx.Done():
return true, ctx.Err()
case <-done:
}
return true, conn.writeTerminalJSON(parsed, release)
default:
return false, conn.writeJSON(parsed)
}
}
lastSeq := -1
for {
// Drain all available events.
events, err := store.GetEventsAfter(responseID, lastSeq)
if err != nil {
return lastSeq, err
}
for _, event := range events {
var parsed schema.ORStreamEvent
if err := json.Unmarshal(event.Data, &parsed); err != nil {
return lastSeq, fmt.Errorf("failed to decode buffered response event: %w", err)
}
terminal, err := writeEvent(&parsed)
if err != nil {
return lastSeq, err
}
if terminal {
return event.SequenceNumber, nil
}
lastSeq = event.SequenceNumber
}
// Check if processing is done and all events have been sent.
select {
case <-done:
finalEvents, err := store.GetEventsAfter(responseID, lastSeq)
if err != nil {
return lastSeq, err
}
for _, event := range finalEvents {
var parsed schema.ORStreamEvent
if err := json.Unmarshal(event.Data, &parsed); err != nil {
return lastSeq, fmt.Errorf("failed to decode buffered response event: %w", err)
}
terminal, err := writeEvent(&parsed)
if err != nil {
return lastSeq, err
}
if terminal {
return event.SequenceNumber, nil
}
lastSeq = event.SequenceNumber
}
return lastSeq, errWSStreamEndedWithoutTerminal
default:
}
// Wait for new events, completion, or context cancellation.
select {
case <-ctx.Done():
return lastSeq, ctx.Err()
case <-done:
// Will drain in next iteration.
case <-eventsChan:
// New events available.
}
}
}
func connectionStoreUsage(store *ResponseStore) (count, bytes int) {
store.mu.RLock()
responses := make([]*StoredResponse, 0, len(store.responses))
for _, stored := range store.responses {
responses = append(responses, stored)
}
store.mu.RUnlock()
for _, stored := range responses {
stored.mu.RLock()
requestData, _ := json.Marshal(stored.Request)
responseData, _ := json.Marshal(stored.Response)
streamBytes := stored.streamBytes
if stored.Response != nil && isORTerminalStatus(stored.Response.Status) {
// The terminal event has already released this response's in-flight
// guard. Exclude its soon-to-be-cleared delivery buffer so the next
// request cannot race cleanup and receive a false quota rejection.
streamBytes = 0
}
bytes += len(requestData) + len(responseData) + streamBytes
stored.mu.RUnlock()
}
return len(responses), bytes
}
func isORTerminalStatus(status string) bool {
return status == schema.ORStatusCompleted || status == schema.ORStatusFailed ||
status == schema.ORStatusIncomplete || status == schema.ORStatusCancelled
}
func connectionStoreCanAccept(store *ResponseStore, incomingBytes, maxResponses, maxBytes int) bool {
count, bytes := connectionStoreUsage(store)
if maxResponses > 0 && count >= maxResponses {
return false
}
return maxBytes <= 0 || bytes+incomingBytes <= maxBytes
}
func writeWSForwardingFailure(conn wsEventWriter, release func(), responseID string, createdAt int64, lastSequence int, input *schema.OpenResponsesRequest, shouldStore bool, forwardingErr error) error {
now := time.Now().Unix()
failedResponse := buildORResponse(responseID, createdAt, &now, schema.ORStatusFailed, input, []schema.ORItemField{}, nil, shouldStore)
event := &schema.ORStreamEvent{
Type: "response.failed",
SequenceNumber: lastSequence + 1,
Response: failedResponse,
Error: &schema.ORErrorPayload{
Type: "server_error",
Message: fmt.Sprintf("response stream failed: %v", forwardingErr),
},
}
normalizeORStreamEvent(event)
return conn.writeTerminalJSON(event, release)
}
func sendWSError(conn *lockedConn, errType, message, param string) {
event := schema.ORStreamEvent{
Type: "error",
Error: &schema.ORErrorPayload{
Type: errType,
Message: message,
Param: param,
},
}
conn.writeJSON(&event)
}
func sendWSErrorAndRelease(conn *lockedConn, release func(), errType, message, param string) {
event := schema.ORStreamEvent{
Type: "error",
Error: &schema.ORErrorPayload{
Type: errType,
Message: message,
Param: param,
},
}
if err := conn.writeTerminalJSON(&event, release); err != nil {
xlog.Debug("WebSocket Responses: failed to write terminal error", "error", err)
}
}
func sendWSErrorEvent(conn *lockedConn, code, message, param string) {
event := schema.ORStreamEvent{
Type: "error",
Error: &schema.ORErrorPayload{
Type: "invalid_request_error",
Code: code,
Message: message,
Param: param,
},
}
conn.writeJSON(&event)
}
func sendWSErrorEventAndRelease(conn *lockedConn, release func(), code, message, param string) {
event := schema.ORStreamEvent{
Type: "error",
Error: &schema.ORErrorPayload{
Type: "invalid_request_error",
Code: code,
Message: message,
Param: param,
},
}
if err := conn.writeTerminalJSON(&event, release); err != nil {
xlog.Debug("WebSocket Responses: failed to write terminal error", "error", err)
}
}