wsproxy

package
v0.0.0-...-0411a9e Latest Latest
Warning

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

Go to latest
Published: Jun 20, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

ws-proxy

Proxies WebSocket connections to the OpenAI Responses API (wss://api.openai.com/v1/responses), injecting provider credentials at upstream dial time and tapping token usage from response.completed frames for billing.

Demonstrates up.RegisterWithGroup — an embedded net/http server running as a background goroutine inside an Envoy dynamic module.

Reference: https://openai.com/index/speeding-up-agentic-workflows-with-websockets/


What problem this solves

POST /v1/responses pays a full HTTP round-trip per turn. For agentic workflows with many sequential tool calls this compounds. OpenAI's WebSocket mode eliminates that:

  • One persistent WS connection handles multiple sequential response.create requests without reconnecting.
  • The server maintains a connection-local in-memory cache of the most recent response. Continuation only needs previous_response_id + new input items — not the full history. Compatible with store: false and Zero Data Retention.
  • Result: ~40% latency reduction for workflows with 20+ tool calls.

The proxy sits between the client and OpenAI, injecting the resolved credential at upstream dial time and extracting token usage for billing.

This is not the Realtime API (/v1/realtime). No audio, no VAD, no voice. This is the standard text/tool Responses API over WebSocket transport.


Architecture

Direct-dial (default)

The embedded server dials the upstream provider directly. Auth header injected in Go before websocket.Dial.

sequenceDiagram
    participant C as Client
    participant E as Envoy :10000
    participant S as WSProxy :10001<br/>(embedded server)
    participant U as api.openai.com

    C->>E: WS upgrade /v1/responses<br/>Authorization: Bearer sk-virtual
    Note over E: ws-proxy filter (no-op for HTTP)<br/>router → cluster ws-proxy-local
    E->>S: WS upgrade (loopback)
    S->>U: WS upgrade<br/>Authorization: Bearer $OPENAI_API_KEY
    Note over S,U: TLS originated by Go websocket.Dial

    loop session
        C->>S: response.create {model, input}
        Note over S: SessionTap.FeedClient → extracts model
        S->>U: forward frame
        U->>S: response.completed {usage}
        Note over S: SessionTap.FeedUpstream → extracts tokens
        S->>C: forward frame
    end

    C-->>S: WS close
    Note over S: recordActorSession → slog + JSON session record
Egress-via-Envoy (WSPROXY_EGRESS_URL set)

The embedded server dials a local Envoy egress listener instead of the upstream directly. Envoy handles TLS origination and credential injection via the ws-auth upstream filter. This keeps auth and TLS management in Envoy, consistent with how the upstream cluster handles non-WS traffic.

sequenceDiagram
    participant C as Client
    participant EI as Envoy :10000<br/>(inbound)
    participant S as WSProxy :10001<br/>(embedded server)
    participant EE as Envoy :10002<br/>(egress listener)
    participant U as api.openai.com

    C->>EI: WS upgrade /v1/responses
    EI->>S: WS upgrade (loopback #1)
    S->>EE: WS upgrade ws://127.0.0.1:10002/v1/responses<br/>(loopback #2, plain — no auth)
    Note over EE: ws-auth upstream filter<br/>injects Authorization: Bearer $OPENAI_API_KEY
    EE->>U: WS upgrade wss://api.openai.com/v1/responses<br/>(TLS originated by Envoy)

    loop session
        C->>S: response.create
        Note over S: SessionTap.FeedClient
        S->>EE: forward
        EE->>U: forward
        U->>EE: response.completed
        EE->>S: forward
        Note over S: SessionTap.FeedUpstream
        S->>C: forward
    end

    C-->>S: WS close
    Note over S: recordActorSession

The two loopback connections are established once per session, not per frame. Per-frame overhead is two extra kernel copies (~microseconds) — negligible against OpenAI network RTT of 50–150 ms.

Why the loopback hop exists

After 101 Switching Protocols, Envoy handles WS frame forwarding as a transparent TCP tunnel. The dynamic module ABI has no per-frame callback. The embedded server is the only way to intercept frames from a dynamic module without modifying Envoy.


Protocol

Connection
wss://api.openai.com/v1/responses
Authorization: Bearer {OPENAI_API_KEY}

Standard WebSocket upgrade. Auth is in the HTTP header at upgrade time. All subsequent frames on the connection inherit that auth. No model in the URL.

Client → server
{
  "type": "response.create",
  "model": "gpt-4.1",
  "store": false,
  "previous_response_id": "resp_abc123",
  "input": [
    {"type": "message", "role": "user",
     "content": [{"type": "input_text", "text": "next step"}]}
  ]
}

previous_response_id omitted on the first request. Subsequent requests within the same session reference the immediately preceding response — full history is not resent.

Server → client
Event Notes
response.created response started
response.output_item.added output item started
response.output_item.delta streaming content chunk
response.output_item.done output item complete
response.completed tap target — contains usage
response.failed response errored
error connection-level error (does not close WS)

Usage is in response.completed:

{
  "type": "response.completed",
  "response": {
    "id": "resp_abc123",
    "usage": {"input_tokens": 210, "output_tokens": 48, "total_tokens": 258}
  }
}
Session semantics
  • One in-flight response.create per connection (sequential, not multiplexed). For parallel requests use separate WS connections.
  • Server enforces a 60-minute connection limit, then sends:
    {"type":"error","error":{"code":"websocket_connection_limit_reached"}}
    
    Client must reconnect and continue with previous_response_id.

Frame tapping

SessionTap intercepts two frame types:

  • response.create (client → upstream): extracts model field.
  • response.completed (upstream → client): extracts input_tokens and output_tokens.

Fast-path: bytes.Contains(frame, []byte("response.completed")) before any json.Unmarshal. The majority of frames (response.output_item.delta, etc.) never reach the JSON decoder.

Both taps run in the pump goroutines and must be non-blocking.


Configuration

All options are read from environment variables at startup (env overrides WSPROXY_*):

Env var Default Notes
WSPROXY_LISTEN_ADDR 127.0.0.1:10001 Embedded server listen address; must match ws-proxy-local cluster
WSPROXY_UPSTREAM_URL wss://api.openai.com Upstream base URL (direct-dial mode)
WSPROXY_AUTH_VALUE Bearer ${OPENAI_API_KEY} Auth header value; ${VAR} expanded
WSPROXY_EGRESS_URL `` (empty) When set, dial this Envoy egress listener instead of upstream directly
WSPROXY_SESSION_LOG `` (empty) Path for JSON-line session records; used by e2e tests

Session record

At session end, recordActorSession emits a structured slog line and, when WSPROXY_SESSION_LOG is set, appends a JSON line to that file:

{"path":"/v1/responses","model":"gpt-4.1","input_tokens":100,"output_tokens":42,"duration_ms":312,"result":"ok","reason":""}

The JSON file is the e2e test assertion target — no log parsing needed.


Envoy config

Inbound listener (:10000)
upgrade_configs: [{upgrade_type: websocket}]
http_filters:
  - ws-proxy      ← no-op for HTTP; starts embedded server via Group
  - router

routes:
  - match: prefix=/ + Upgrade=websocket → cluster ws-proxy-local (loopback)
  - match: prefix=/                      → cluster mock-upstream (non-WS)
Egress listener (:10002, egress-via-Envoy mode)
upgrade_configs: [{upgrade_type: websocket}]
http_filters:
  - router

routes:
  - match: prefix=/ + Upgrade=websocket → cluster ws-proxy-egress
      (ws-auth upstream filter injects Bearer token, Envoy dials api.openai.com over TLS)

Files

examples/ws-proxy/
  README.md               this file
  ws_proxy.go             WSProxy, SessionTap, frame pump, Register()
  auth.go                 ws-auth upstream HTTP filter (credential injection)
  observability.go        SessionRecord, InitSessionLog, recordActorSession
  ws_proxy_test.go        unit tests for SessionTap
  envoy.yaml              reference Envoy config (direct-dial + egress-via-Envoy)
  Makefile
  e2e/
    e2e_test.go           end-to-end tests (mock upstream, no real API key)
    testdata/
      envoy.tmpl.yaml     Envoy config template rendered per test run

Running

# Build
make -C examples/ws-proxy build

# Unit tests (no Envoy)
make -C examples/ws-proxy test

# E2e tests (mock upstream, no API key needed)
make -C examples/ws-proxy e2e

# Run against real OpenAI
OPENAI_API_KEY=sk-... make -C examples/ws-proxy run
# then connect:
wscat -c ws://localhost:10000/v1/responses -H 'Authorization: Bearer sk-test'

Out of scope

  • Real credential cache / virtual key validation (P2: CredentialCache.Pin())
  • previous_response_id tracking — proxy forwards response.create verbatim
  • Reconnect on websocket_connection_limit_reached — client responsibility
  • OTLP metric export (ws_proxy_sessions_total, ws_proxy_session_duration_ms) — P3
  • Fraser4 integration (PolicyCache, CredentialCache, Valet DB) — P4
  • Envoy Gateway / EPP cluster injection (integrations/ws-proxy-eg/) — P1
  • Realtime API (/v1/realtime) — separate protocol

Documentation

Overview

Package wsproxy demonstrates the embedded WebSocket proxy pattern using up.Register + up.WithGroup, with a complete implementation for the OpenAI Responses API WebSocket mode at /v1/responses.

Protocol: OpenAI Responses API WebSocket mode (/v1/responses)

The client sends one message to start a response:

{"type":"response.create","model":"gpt-4.1","input":[...],"max_output_tokens":N}

The server streams events back:

{"type":"response.created", ...}
{"type":"response.output_item.delta", "delta":"Hi"}   // text chunks
{"type":"response.output_item.done",  ...}
{"type":"response.completed", "response":{"usage":{"input_tokens":N,"output_tokens":M}}}

The SessionTap taps every frame:

  • Client → Upstream: extracts model name from response.create
  • Upstream → Client: extracts token usage from response.completed

Architecture

Direct-dial (default):

Client ──WS──► Envoy :10000
                  │  ──► ws-proxy-local (127.0.0.1:10001)
                  │              │
                  │      WSProxy.ServeHTTP
                  │              │  ──► wss://api.openai.com/v1/responses
                  │              │      Authorization: Bearer $OPENAI_API_KEY
                  │
Normal HTTP ──► upstream cluster (TLS + ws-auth upstream filter)

Egress-via-Envoy (WSPROXY_EGRESS_URL set):

Client ──WS──► Envoy :10000
                  │  ──► ws-proxy-local (127.0.0.1:10001)
                  │              │
                  │      WSProxy.ServeHTTP
                  │              │  ──► ws://127.0.0.1:10002 (egress listener)
                  │                              │
                  │                      Envoy :10002
                  │                              │  ws-auth injects Bearer token
                  │                              │  ──► wss://api.openai.com/v1/responses

Index

Constants

View Source
const AuthExtensionName = "ws-auth"
View Source
const ExtensionName = "ws-proxy"

Variables

This section is empty.

Functions

func InitSessionLog

func InitSessionLog(path string)

InitSessionLog enables JSON-line session logging to path. Called from Register() when WSPROXY_SESSION_LOG is set.

func Register

func Register()

Register wires the ws-proxy filter into Envoy via up.Register + up.WithSidecar. The filter itself is a no-op for regular HTTP — it only starts the embedded WebSocket proxy server via the Sidecar. ws-auth (upstream filter) is registered separately in auth.go's init().

func RegisterDirect

func RegisterDirect(name string, cfg Config)

RegisterDirect registers a second named filter instance in direct-dial mode.

func ResolveEnv

func ResolveEnv(v string) string

ResolveEnv expands ${ENV_VAR} references in v using os.Expand. Returns v unchanged if the variable is unset.

Types

type AuthConfig

type AuthConfig struct {
	// StripHeaders are request headers removed before forwarding upstream.
	StripHeaders []string

	// InjectHeaders are headers added to the upstream request.
	// Values support ${ENV_VAR} expansion.
	InjectHeaders map[string]string
}

AuthConfig holds credentials for the upstream cluster. Loaded once at filter config time from environment variables.

func DefaultAuthConfig

func DefaultAuthConfig() AuthConfig

DefaultAuthConfig returns the default config reading from well-known env vars.

type Config

type Config struct {
	// UpstreamURL is the upstream wss:// base URL.
	// Default: "wss://api.openai.com"
	UpstreamURL string `json:"upstream_url"`

	// AuthHeader is the HTTP header name injected when dialing upstream.
	// Default: "authorization"
	AuthHeader string `json:"auth_header"`

	// AuthValue is the header value. Supports ${ENV_VAR} expansion.
	// Default: "Bearer ${OPENAI_API_KEY}"
	AuthValue string `json:"auth_value"`

	// ShutdownTimeout for graceful shutdown. Default: "5s".
	ShutdownTimeout string `json:"shutdown_timeout"`

	// ListenAddress is the loopback address for the embedded proxy server.
	// Default: "127.0.0.1:10001". Must match the ws-proxy-local cluster in envoy.yaml.
	ListenAddress string `json:"listen_addr"`

	// EgressURL, when set, makes the embedded server dial this local Envoy listener
	// instead of UpstreamURL. Envoy handles TLS origination and credential injection
	// (ws-auth upstream filter) on the egress cluster.
	// Example: "ws://127.0.0.1:10002". Default: "" (direct dial).
	EgressURL string `json:"egress_url"`

	// OTELEndpoint enables actor-side OTLP/gRPC metrics export when set.
	// Requires github.com/dio/logging + github.com/tetratelabs/telemetry.
	// Example: "127.0.0.1:4317".
	OTELEndpoint string `json:"otel_endpoint"`

	// OTELExportInterval controls actor-side metric export cadence.
	// Default: "1s".
	OTELExportInterval string `json:"otel_export_interval"`
}

Config is the JSON config for the ws-proxy filter.

type SessionRecord

type SessionRecord struct {
	Path         string `json:"path"`
	Model        string `json:"model"`
	InputTokens  uint32 `json:"input_tokens"`
	OutputTokens uint32 `json:"output_tokens"`
	DurationMS   int64  `json:"duration_ms"`
	Result       string `json:"result"`
	Reason       string `json:"reason"`
}

SessionRecord is the JSON shape written to sessionLogPath per session. Exported so e2e can unmarshal and assert without string matching.

type SessionTap

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

SessionTap extracts the model name and token usage from OpenAI Responses frames. Created fresh for each WebSocket session. Exported for unit testing.

func NewSessionTap

func NewSessionTap() *SessionTap

NewSessionTap creates a new SessionTap.

func (*SessionTap) FeedClient

func (t *SessionTap) FeedClient(data []byte)

FeedClient taps the response.create frame (first client message) to extract the model name. All subsequent client frames are ignored once model is known.

func (*SessionTap) FeedUpstream

func (t *SessionTap) FeedUpstream(data []byte)

FeedUpstream taps the response.completed frame (final server event) to extract token usage. All other upstream frames are ignored.

func (*SessionTap) Model

func (t *SessionTap) Model() string

Model returns the model name extracted from response.create, or "" if not yet seen.

func (*SessionTap) Usage

func (t *SessionTap) Usage() TokenUsage

Usage returns the token usage extracted from response.completed.

type TokenUsage

type TokenUsage struct {
	InputTokens  uint32
	OutputTokens uint32
}

TokenUsage holds the token counts from the completed response.

type WSProxy

type WSProxy struct {

	// OnClientFrame is called for each text frame the client sends.
	// Runs in the pump goroutine — must be fast and non-blocking.
	OnClientFrame func(websocket.MessageType, []byte)

	// OnUpstreamFrame is called for each text frame received from upstream.
	// Runs in the pump goroutine — must be fast and non-blocking.
	OnUpstreamFrame func(websocket.MessageType, []byte)
	// contains filtered or unexported fields
}

WSProxy is an http.Handler that proxies WebSocket connections to an upstream provider, tapping frames for model extraction and token counting.

func NewProxy

func NewProxy(upstreamURL, authHeader, authValue string) *WSProxy

NewProxy creates a WSProxy for direct use in tests or without the Envoy factory.

func (*WSProxy) ServeHTTP

func (p *WSProxy) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP accepts a WebSocket upgrade from Envoy, dials the upstream, and runs the bidirectional frame pump with per-session tapping.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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