httpstream

package
v1.9.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 4, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package httpstream provides HTTP streaming wire formats for Nexus.

It owns the encoders that translate provider.StreamChunk events into Server-Sent Events, NDJSON, WebSocket frames, or any custom format registered by the embedder. Encoders are pluggable via Registry; the proxy and api packages negotiate one based on the request's Accept header (or an explicit format override) and feed it to Run, which is the single shared event loop with heartbeats, cancellation, and sanitised error events.

Index

Constants

This section is empty.

Variables

View Source
var ErrClientWrite = errors.New("httpstream: write to the client failed")

ErrClientWrite wraps an error writing an event to the client. It means the client went away (it closed the tab, hit stop, lost its network), which is not a gateway failure.

Functions

func ClientGone

func ClientGone(ctx context.Context, err error) bool

ClientGone reports whether a stream error means only that the client left: the request context is done, the error wraps ErrClientWrite, or it is a broken pipe. A stream hook skips logging these, because operators alert on errors and a client pressing stop is not one. A connection reset is not matched on its own: a provider's reset looks the same and must be logged.

func Run

func Run(ctx context.Context, w http.ResponseWriter, stream provider.Stream, encoder StreamEncoder, opts RunOptions)

Run is the single shared streaming event loop used by both /v1/chat/ completions (proxy) and the native nexus completions endpoint (api). It writes encoder.WriteHeaders, drains stream.Next in a loop, fires heartbeats during idle periods, sanitizes mid-stream errors, calls stream.Close exactly once, and writes encoder.End before returning.

Cancellation: when ctx is canceled (HTTP client disconnect, server shutdown, abort frame on a WebSocket), the runner stops emitting and closes the stream. The provider stream's own ctx-AfterFunc tears the upstream connection.

Concurrency: a single goroutine drives the loop; heartbeats are scheduled with a time.Ticker on the same goroutine using a non-blocking select. Encoder writes are not concurrent with each other.

Types

type CompletionStreamer

type CompletionStreamer interface {
	CompleteStream(ctx context.Context, req *provider.CompletionRequest) (provider.Stream, error)
}

CompletionStreamer is the minimal interface WSHandler needs from the nexus gateway. Engine.CompleteStream satisfies this implicitly.

type EventType

type EventType string

EventType discriminates the wire-level event kind.

EventType is the on-the-wire form; provider.EventKind is the in-process form. Encoders translate between them. Some types (Done, Heartbeat) have no provider counterpart — they're synthesised by the runner.

const (
	EventTypeDelta     EventType = "delta"
	EventTypeReasoning EventType = "reasoning"
	EventTypeToolCall  EventType = "tool_call"
	EventTypeAudio     EventType = "audio"
	EventTypeImage     EventType = "image"
	EventTypeCitation  EventType = "citation"
	EventTypeUsage     EventType = "usage"
	EventTypeError     EventType = "error"
	EventTypeHeartbeat EventType = "heartbeat"
	EventTypeDone      EventType = "done"
)

type NDJSONEncoder

type NDJSONEncoder struct{}

NDJSONEncoder emits one JSON object per line, native to the StreamEvent shape (not OpenAI-compatible). Each line is a discriminated event with a `type` field. Useful for non-browser consumers (curl -N, Go HTTP clients).

Example output:

{"type":"delta","model":"gpt-4o","delta":{"content":"Hello"}}
{"type":"reasoning","delta":{"reasoning":"Let me think..."}}
{"type":"tool_call","delta":{"tool_calls":[{"index":0,...}]}}
{"type":"usage","usage":{"prompt_tokens":5,...}}
{"type":"done"}

func NewNDJSONEncoder

func NewNDJSONEncoder() *NDJSONEncoder

NewNDJSONEncoder returns a fresh NDJSON encoder.

func (*NDJSONEncoder) ContentType

func (e *NDJSONEncoder) ContentType() string

func (*NDJSONEncoder) EncodeError

func (e *NDJSONEncoder) EncodeError(w io.Writer, werr *WireError) error

func (*NDJSONEncoder) EncodeEvent

func (e *NDJSONEncoder) EncodeEvent(w io.Writer, ev *StreamEvent) error

func (*NDJSONEncoder) End

func (e *NDJSONEncoder) End(w io.Writer) error

func (*NDJSONEncoder) Heartbeat

func (e *NDJSONEncoder) Heartbeat(w io.Writer) error

func (*NDJSONEncoder) WriteHeaders

func (e *NDJSONEncoder) WriteHeaders(w http.ResponseWriter)

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry maps content types to encoders, plus aliases for convenience (e.g. "sse" → "text/event-stream"). Safe for concurrent registration during construction; treat as read-only after first request handled.

func DefaultRegistry

func DefaultRegistry() *Registry

DefaultRegistry returns a registry pre-populated with the standard encoders:

  • text/event-stream — SSE in OpenAI chat.completion.chunk envelope (default; required for OpenAI SDK compatibility).
  • application/vnd.nexus.events+sse — SSE with named events, surfaces every kind (reasoning, tool_call, …) for nexus-aware clients.
  • application/x-ndjson — line-delimited JSON of native StreamEvent.

Aliases registered: "sse"/"openai" → OpenAI SSE; "nexus"/"nexus-sse" → native SSE; "ndjson"/"jsonl" → NDJSON.

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates an empty registry. Use DefaultRegistry for one pre-seeded with SSE-OpenAI / SSE-native / NDJSON encoders.

func (*Registry) Default

func (r *Registry) Default() StreamEncoder

Default returns the default encoder, if registered.

func (*Registry) Lookup

func (r *Registry) Lookup(name string) StreamEncoder

Lookup resolves a content type or alias to its encoder. Returns nil if neither matches.

func (*Registry) Register

func (r *Registry) Register(contentType string, enc StreamEncoder)

Register adds an encoder for a content type. The first registered encoder becomes the default unless SetDefault is called.

func (*Registry) RegisterAlias

func (r *Registry) RegisterAlias(alias, contentType string)

RegisterAlias maps an alias name to a registered content type (e.g. "ndjson" → "application/x-ndjson").

func (*Registry) SetDefault

func (r *Registry) SetDefault(contentType string)

SetDefault picks the encoder used when no Accept / format override matches.

type RunOptions

type RunOptions struct {
	// HeartbeatInterval controls keepalive emission. The runner only sends
	// a heartbeat if no real event was flushed within the interval.
	// Zero defaults to 15s. Set to a negative value to disable.
	HeartbeatInterval time.Duration

	// RequestID is echoed into wire events for client-side correlation.
	RequestID string

	// OnError is called once when the stream terminates with an error
	// (after the encoder has emitted the typed error event but before
	// End is called). Useful for recording mid-stream failures upstream.
	// A failed write to the client is passed wrapped in ErrClientWrite;
	// use ClientGone to tell it from a provider failure.
	OnError func(error)
}

RunOptions tunes the streaming event loop. Zero value is reasonable production defaults: 15-second heartbeat, no per-event timeout, accept the request id from the caller.

type SSENativeEncoder

type SSENativeEncoder struct{}

SSENativeEncoder emits events in nexus-native SSE form: each event uses a named SSE event (event: delta, event: reasoning, event: tool_call, etc.) and the data line is the raw StreamEvent JSON. This is more expressive than the OpenAI-compatible envelope — clients can route on event name without parsing the payload — but it's not compatible with OpenAI SDKs.

Use this when you control the consumer (a Go SDK, a custom dashboard). Consumers in OpenAI-SDK ecosystems should keep the SSEOpenAIEncoder.

func NewSSENativeEncoder

func NewSSENativeEncoder() *SSENativeEncoder

NewSSENativeEncoder returns a fresh native SSE encoder.

func (*SSENativeEncoder) ContentType

func (e *SSENativeEncoder) ContentType() string

func (*SSENativeEncoder) EncodeError

func (e *SSENativeEncoder) EncodeError(w io.Writer, werr *WireError) error

func (*SSENativeEncoder) EncodeEvent

func (e *SSENativeEncoder) EncodeEvent(w io.Writer, ev *StreamEvent) error

func (*SSENativeEncoder) End

func (e *SSENativeEncoder) End(w io.Writer) error

func (*SSENativeEncoder) Heartbeat

func (e *SSENativeEncoder) Heartbeat(w io.Writer) error

func (*SSENativeEncoder) WriteHeaders

func (e *SSENativeEncoder) WriteHeaders(w http.ResponseWriter)

type SSEOpenAIEncoder

type SSEOpenAIEncoder struct{}

SSEOpenAIEncoder emits events in the OpenAI chat.completion.chunk envelope over Server-Sent Events. The intent is drop-in compatibility with OpenAI SDKs (openai-python, openai-go, openai-node, etc.).

Tool-call deltas are forwarded with their `index`/`function.name`/ `function.arguments` fields preserved. Reasoning is surfaced as `delta.reasoning_content` (Anthropic-on-OpenAI extension already used by DeepSeek and other gateways) so OpenAI clients silently ignore it while nexus-aware clients can read it.

Final usage chunk follows OpenAI's stream_options.include_usage shape:

data: {"id":"…","object":"chat.completion.chunk","choices":[],"usage":{...}}
data: [DONE]

func NewSSEOpenAIEncoder

func NewSSEOpenAIEncoder() *SSEOpenAIEncoder

NewSSEOpenAIEncoder returns the default OpenAI-compatible SSE encoder.

func (*SSEOpenAIEncoder) ContentType

func (e *SSEOpenAIEncoder) ContentType() string

func (*SSEOpenAIEncoder) EncodeError

func (e *SSEOpenAIEncoder) EncodeError(w io.Writer, werr *WireError) error

func (*SSEOpenAIEncoder) EncodeEvent

func (e *SSEOpenAIEncoder) EncodeEvent(w io.Writer, ev *StreamEvent) error

func (*SSEOpenAIEncoder) End

func (e *SSEOpenAIEncoder) End(w io.Writer) error

func (*SSEOpenAIEncoder) Heartbeat

func (e *SSEOpenAIEncoder) Heartbeat(w io.Writer) error

func (*SSEOpenAIEncoder) WriteHeaders

func (e *SSEOpenAIEncoder) WriteHeaders(w http.ResponseWriter)

type StreamEncoder

type StreamEncoder interface {
	// ContentType is the canonical Content-Type produced by this encoder.
	ContentType() string

	// WriteHeaders writes status + headers and prepares the response for
	// streaming. Implementations should set Cache-Control: no-cache, the
	// Content-Type, and disable buffering by flushing the headers.
	WriteHeaders(w http.ResponseWriter)

	// EncodeEvent writes one event. Errors abort the stream.
	EncodeEvent(w io.Writer, e *StreamEvent) error

	// EncodeError writes a typed error event. Encoders that support named
	// events (SSE) emit a discriminated frame; NDJSON/WS just emit a
	// type:"error" object.
	EncodeError(w io.Writer, err *WireError) error

	// Heartbeat writes a keepalive frame (SSE comment, NDJSON heartbeat
	// line, or WS ping). May be a no-op for protocols with native pings.
	Heartbeat(w io.Writer) error

	// End writes any required terminator (SSE [DONE], NDJSON {"type":"done"})
	// and flushes. Does not close the underlying connection.
	End(w io.Writer) error
}

StreamEncoder writes StreamEvents to an HTTP response in a wire format.

All methods may be called from a single goroutine — encoders need not be concurrency-safe. WriteHeaders runs once before the first event; End runs once after the last (or after an error event, before the connection closes). Heartbeat is best-effort: if writing fails, the runner gives up and lets the next data write surface the error.

func Negotiate

func Negotiate(r *http.Request, reg *Registry) StreamEncoder

Negotiate picks the encoder a client requested, falling back to the registry default. Order of precedence:

  1. ?stream_format= query parameter
  2. X-Nexus-Stream-Format header
  3. Accept header — first matching encoder
  4. Registry default

The matcher is case-insensitive and accepts both content-type values ("application/x-ndjson") and aliases ("ndjson", "sse"). Unknown values fall through to the next step.

type StreamEvent

type StreamEvent struct {
	Type         EventType            `json:"type"`
	ID           string               `json:"id,omitempty"`
	Model        string               `json:"model,omitempty"`
	RequestID    string               `json:"request_id,omitempty"`
	Delta        *provider.Delta      `json:"delta,omitempty"`
	Usage        *provider.Usage      `json:"usage,omitempty"`
	Citation     *provider.Citation   `json:"citation,omitempty"`
	Audio        *provider.AudioChunk `json:"audio,omitempty"`
	Image        *provider.ImageChunk `json:"image,omitempty"`
	FinishReason string               `json:"finish_reason,omitempty"`
	Err          *WireError           `json:"error,omitempty"`
}

StreamEvent is the encoder-facing representation of a chunk.

Encoders should populate only the fields relevant to Type. Unset fields are omitted on the wire. ID/Model/RequestID are echoed when known.

func FromChunk

func FromChunk(c *provider.StreamChunk, requestID string) *StreamEvent

FromChunk maps a provider.StreamChunk into a StreamEvent.

Provider impls populate Delta with mixed semantics (text content + tool calls + reasoning), so the mapping picks an EventType based on the chunk Kind first, then the most specific delta field present.

type WSHandler

type WSHandler struct {
	// contains filtered or unexported fields
}

WSHandler upgrades an HTTP request to a WebSocket connection that streams nexus events as JSON text frames. The protocol is:

client → server (text JSON):
  {"type":"start","request":{...CompletionRequest...}}
  {"type":"abort"}
  {"type":"audio_chunk","format":"pcm16","b64":"..."}    (multi-modal in)
  {"type":"image","mime_type":"image/png","b64":"..."}   (multi-modal in)

server → client (text JSON):
  StreamEvent objects (same shape as NDJSON)
  Final {"type":"done"} then graceful close.
  {"type":"error", error:{...}} on failure then close StatusInternalError,
  or StatusPolicyViolation when the gateway refused the request.

The handler is constructed with a CompletionStreamer — typically the gateway engine — that produces the provider.Stream for a parsed request. Auth lives in the parent middleware chain; the handler reads from r.Context() and respects auth/tenant decisions made before the upgrade.

func NewWSHandler

func NewWSHandler(streamer CompletionStreamer, opts WSOptions) *WSHandler

NewWSHandler returns a WebSocket handler.

func (*WSHandler) ServeHTTP

func (h *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type WSOptions

type WSOptions struct {
	// AcceptOrigins is the allowed list for the Origin check. Empty means
	// the same-origin policy (nhooyr default). Pass `[]string{"*"}` to
	// allow any origin (use only for trusted networks).
	AcceptOrigins []string

	// SendBuffer is the buffered channel size feeding the writer goroutine.
	// Default: 64.
	SendBuffer int

	// HeartbeatInterval, when > 0, sends WS pings on the configured cadence
	// to keep the connection alive across idle proxies. Default: 20s.
	HeartbeatInterval time.Duration

	// OnError is called once when a stream fails, with the error as the
	// stream returned it. The client sees only the sanitized envelope, so
	// this is where the cause is logged. ctx carries the request id.
	OnError func(ctx context.Context, err error)
}

WSOptions configures the WebSocket handler.

type WireError

type WireError struct {
	Message   string `json:"message"`
	Type      string `json:"type"`
	Code      string `json:"code,omitempty"`
	Retryable bool   `json:"retryable"`
	RequestID string `json:"request_id,omitempty"`
}

WireError is the sanitised error envelope. Internal error strings are intentionally not exposed; consumers see a Type, a request id, and a retryable flag.

func SanitizeError

func SanitizeError(err error, requestID string) *WireError

SanitizeError converts an in-process error into a wire-safe envelope. Mapping rules: a pipeline.Refusal says so (Type "refused", its own code and its own text, retryable when it is a 429); a cancel or timeout keeps its type; everything else gets a fixed message so no stack, URL or system detail leaks. Only the refusal's own text is used, never a wrapper's.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL