v2

package
v0.1.3 Latest Latest
Warning

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

Go to latest
Published: Jun 6, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package v2 implements the hot-path BEFORE / AFTER plugin contract.

It coexists with the observe-class plugin scaffold under github.com/2nd1st/api-log/internal/plugin; both packages are buildable independent surfaces.

The "interfere class" is bounded by two call sites (post-receive / pre-forward and post-upstream / pre-client-send), honored fail-open per the spec, and recorded post-mutation only. The current contract does NOT carry tool_call argument mutation on the AFTER hook; streaming tool_use input_json_delta events pass through untouched.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildRequestBody

func BuildRequestBody(req *ParsedRequest) ([]byte, error)

BuildRequestBody serializes a ParsedRequest back to raw JSON for the forward path. Used when a BEFORE plugin returns ActionMutate and the framework needs to forward the rewritten request upstream.

Per-protocol writers, mirroring the parsed.go dispatchers. Gemini is deferred (less streaming demand for v1; the request shape is preserved verbatim from RawBody when no Mutate landed).

Equivalence target: the output JSON when round-tripped (Unmarshal to map[string]any) is semantically equal to the input for fields v1 normalizes. Bytes are NOT promised equal — field ordering, optional whitespace, and shapes the normalizer does not lift are not reconstructed.

func BuildResponseBody

func BuildResponseBody(resp *ParsedResponse) ([]byte, error)

BuildResponseBody serializes a ParsedResponse back to raw JSON. Used in the non-streaming AFTER-mutation path. Streaming responses are mutated event-by-event through the stream dispatcher and do not go through this function.

func IsSSEContentType

func IsSSEContentType(ct string) bool

IsSSEContentType returns true when the header value indicates an SSE stream — anything starting with "text/event-stream", case-insensitive. The AFTER-hook bridge in main.go uses this to pick streaming vs buffered handling.

func MatchRoute

func MatchRoute(routes []string, path string) bool

MatchRoute is a convenience for callers that don't want to hold a compiled matcher (one-shot uses, tests). Identical semantics to CompileRoutes + Matches; allocates per call.

func RegisterBuiltin

func RegisterBuiltin(typeName string, ctor Ctor)

RegisterBuiltin records a builtin plugin type's factory. Called from each builtin package's init(). Duplicate type-names panic at process start so misconfiguration cannot ship.

func ResetBuiltinsForTest

func ResetBuiltinsForTest()

ResetBuiltinsForTest clears the builtin map. TEST-ONLY — used by registry_test to isolate cases that register temporary type names. Not exposed in production code paths.

func RewriteDeltaText

func RewriteDeltaText(proto Protocol, classified ClassifiedEvent, newText string) (sse.Event, error)

RewriteDeltaText returns a copy of ev with its text payload replaced by newText. Per-protocol writers; the wrapping envelope (deltas, frame metadata) is preserved verbatim.

Pre-condition: ev was classified as ClassContentDelta or ClassReasoningDelta and the dispatcher is passing back the same event with a transformed Text. For other classes the function returns the event unchanged.

func SynthesizeContentDelta

func SynthesizeContentDelta(proto Protocol, text string) (sse.Event, error)

SynthesizeContentDelta builds a new sse.Event carrying the given text as the content delta payload, in the protocol's expected event shape. Used by EmitBeforeFinish to land a final framework-emitted text fragment just before the terminal event.

Returns an error for protocols where the synthesis shape is not pinned down (Gemini in v1).

Types

type Action

type Action int

Action is the verdict a plugin returns from a hook call.

See spec §2.2 for the contract; see ActionXxx constants below for the per-action semantics.

const (
	// ActionContinue means "I have nothing to do." The next plugin sees
	// the same parsed object the framework handed me.
	ActionContinue Action = iota

	// ActionMutate means "I produced a replacement." The framework
	// replaces the parsed object (request on BEFORE, response on AFTER
	// non-streaming) and passes it to the next plugin.
	ActionMutate

	// ActionIntercept means "I produced the final response." The
	// framework short-circuits the rest of the pipeline:
	//   - BEFORE intercept: skip upstream forwarding; serve the intercept
	//     body to the client. The full AFTER chain still runs on the
	//     synthesized response so decorators (watermark, etc.) still
	//     get a chance.
	//   - AFTER  intercept: remaining AFTER plugins are skipped; the
	//     intercept body replaces what gets streamed to the client.
	ActionIntercept
)

type AfterContext

type AfterContext struct {
	// Response is the buffered upstream response. Non-nil in the
	// non-streaming branch; nil when streaming.
	Response *ParsedResponse
	// contains filtered or unexported fields
}

AfterContext is the per-call collaboration handle the framework passes to AfterPlugin.OnAfter. Field availability depends on req.Streaming.

func (*AfterContext) BeforeFinishCallbacks deprecated

func (ac *AfterContext) BeforeFinishCallbacks() []func(emit func(text string))

BeforeFinishCallbacks returns the registered before-finish callbacks in registration order.

Deprecated: the framework no longer invokes these callbacks (R1 amendment). Retained so tests and external consumers compiled against R0 still build. Use LastTextDeltaCallbacks instead.

func (*AfterContext) ContentDeltaTransforms

func (ac *AfterContext) ContentDeltaTransforms() []func(string) string

ContentDeltaTransforms returns the registered content-delta transforms in registration order. Exported for the stream dispatcher and tests; plugin authors should use OnContentDelta to register.

func (*AfterContext) EmitBeforeFinish deprecated

func (ac *AfterContext) EmitBeforeFinish(fn func(emit func(text string)))

EmitBeforeFinish is retained as a no-op registration for source compatibility with R0 plugins. Callbacks registered here are NEVER fired by the framework; new code should use OnLastTextDelta.

Deprecated: prefer OnLastTextDelta. See AfterContext.beforeFinish for the migration rationale.

func (*AfterContext) LastTextDeltaCallbacks

func (ac *AfterContext) LastTextDeltaCallbacks() []func(blockIndex int, text string) string

LastTextDeltaCallbacks returns the registered last-text-delta transforms in registration order. Exported for the stream dispatcher and tests; plugin authors should use OnLastTextDelta to register.

func (*AfterContext) OnContentDelta

func (ac *AfterContext) OnContentDelta(fn func(text string) string)

OnContentDelta registers a transform applied to each content delta text payload as the stream flows. Streaming branch only — no-op when AfterContext.Response is non-nil (the non-streaming code path does not invoke registered callbacks).

The transform receives the delta text (after the classifier extracted it from the protocol-specific event) and returns the replacement text. Returning the input unchanged is fine and cheap.

IMPORTANT: tool_use input_json_delta events are a distinct classifier class and NEVER reach this chain (spec §10.6). Plugins do not need to defensively check for tool-call arg JSON inside delta text.

func (*AfterContext) OnLastTextDelta

func (ac *AfterContext) OnLastTextDelta(fn func(blockIndex int, text string) string)

OnLastTextDelta registers a transform that runs on the LAST text-delta of each content block (block index passed in), giving plugins a place to land an "ends-with" mutation that produces a wire-valid sequence: the buffered delta is rewritten in place and followed by the block's protocol-specific terminator (content_block_stop / response.output_text.done) without any synthesized event after the overall stream terminator.

Used by text-append AFTER mode for the "policy footer" use case. Returning the input unchanged is cheap and safe.

IMPORTANT: tool_use input_json_delta events are a distinct classifier class and NEVER reach this chain (spec §10.6 carve-out).

func (*AfterContext) OnReasoningDelta

func (ac *AfterContext) OnReasoningDelta(fn func(text string) string)

OnReasoningDelta registers a transform for reasoning/thinking text delta events (Anthropic thinking_delta, OpenAI Responses reasoning summary delta). Same semantics as OnContentDelta.

func (*AfterContext) ReasoningDeltaTransforms

func (ac *AfterContext) ReasoningDeltaTransforms() []func(string) string

ReasoningDeltaTransforms returns the registered reasoning-delta transforms in registration order.

type AfterPlugin

type AfterPlugin interface {
	Name() string
	OnAfter(ctx context.Context, req *ParsedRequest, ac *AfterContext, cfg map[string]any) AfterResult
}

AfterPlugin is the AFTER-hook contract: post-upstream-response, pre-client-send.

Two branches, dispatched by req.Streaming:

  • Non-streaming. AfterContext.Response is non-nil. The plugin inspects req+ac.Response, returns {Action: ActionMutate, Mutated: <new ParsedResponse>} or {Action: ActionContinue} or {Action: ActionIntercept, Intercept: <...>}.

  • Streaming. AfterContext.Response is nil. The plugin registers semantic callbacks on ac (OnContentDelta, OnReasoningDelta, OnLastTextDelta) — see AfterContext below — then returns {Action: ActionContinue}. The dispatcher applies the registered transforms as events flow. ActionMutate is ignored in the streaming branch (there is no buffered response to replace); ActionIntercept still works (the dispatcher drops the stream and serves the intercept body).

OnAfter is a ONE-SHOT registration call in the streaming branch, not a per-event call. Per-event work happens inside the callbacks.

Forward compatibility (spec §10.6): Phase D will add an opt-in ToolCallMutator interface detected via type assertion (Go stdlib's io.WriterTo / io.ReaderFrom evolution pattern). Existing AfterPlugin implementations will NOT need to change. Plugins implementing ToolCallMutator will gain an

OnToolCall(ctx, req, call *ToolCall) ToolCallResult

callback for buffered tool-call arguments (Continue / Mutate the args / Intercept the single call). The dispatcher will spin up the buffer-then-expose machinery only when at least one registered plugin opts in via ToolCallMutator. The name "ToolCallMutator" is the verbatim interface identifier Phase D will land in this package; it is referenced here so a search for the name surfaces both the commitment and this note.

type AfterResult

type AfterResult struct {
	Action    Action
	Mutated   *ParsedResponse
	Intercept *InterceptResponse
}

AfterResult is the return value of AfterPlugin.OnAfter.

In the streaming branch the plugin typically returns {Action: ActionContinue} after registering its callbacks on the AfterContext (see hook.go); the dispatcher applies the registered transforms as events flow. Mutated has no effect in the streaming branch — there is no buffered response to replace.

In the non-streaming branch the plugin returns {Action: ActionMutate, Mutated: <new ParsedResponse>} to replace the buffered response.

Intercept works in both branches: the framework drops what it has and serves the intercept body instead.

type BeforePlugin

type BeforePlugin interface {
	// Name returns the stable type identifier. Used in trace markers,
	// logs, and the viewer Settings dropdown.
	Name() string

	// OnBefore inspects req and decides the next action.
	//
	// cfg is the per-instance config blob, passed by-value to each call
	// so plugins do not have to manage their own atomic-load. cfg is a
	// shallow copy; plugins that mutate map values inside the call may
	// race with other goroutines reading the same instance.
	OnBefore(ctx context.Context, req *ParsedRequest, cfg map[string]any) BeforeResult
}

BeforePlugin is the BEFORE-hook contract: post-receive, pre-forward.

Implementations:

  • Treat ParsedRequest as logically read-only. Return a Mutated copy when you want to change a field.
  • Never return error; if you fail internally, log via slog and return {Action: ActionContinue}. The dispatcher fails open.
  • Panics are recovered by the dispatcher (recover.go) with the same fail-open semantics.

Name is the TYPE name (e.g. "watermark", "text-replace"). The per-instance ID is held by the Registry and is not the plugin's concern. Plugin authors typically embed a zero-value baseline and override Name only.

type BeforeResult

type BeforeResult struct {
	Action    Action
	Mutated   *ParsedRequest
	Intercept *InterceptResponse
}

BeforeResult is the return value of BeforePlugin.OnBefore.

Field meaning by Action:

  • ActionContinue : Mutated and Intercept are nil; framework keeps the incoming request.
  • ActionMutate : Mutated is the replacement request; Intercept is nil. If Mutated is nil despite ActionMutate, the framework treats it as ActionContinue (defensive; logged at WARN).
  • ActionIntercept: Intercept is the synthesized response; Mutated is nil. If Intercept is nil despite ActionIntercept, the framework treats it as ActionContinue (defensive).

type ClassifiedEvent

type ClassifiedEvent struct {
	Event      sse.Event
	Class      EventClass
	Text       string
	BlockIndex int
}

ClassifiedEvent is the dispatcher's working tuple. Text is the extracted text payload when Class is ContentDelta / ReasoningDelta; empty otherwise. Mutator writes a new event back (with the same envelope and a substituted text payload).

BlockIndex is the content-block index for ContentDelta and ContentBlockStop events. Anthropic Messages carries this in the wire format ("index" field); other protocols use 0 as the virtual block index for the single text stream they emit. The dispatcher uses BlockIndex to pair a buffered last-delta with the right ContentBlockStop.

func Classify

func Classify(proto Protocol, ev sse.Event) ClassifiedEvent

Classify computes the semantic class of an SSE event for the given protocol, extracting the text payload for content/reasoning deltas.

Tool-use deltas are recognized and returned as ClassToolUseDelta; their text payload is intentionally NOT extracted (the dispatcher must not have a path that could route tool-call JSON into a content transform).

Unknown event shapes return ClassOther; the dispatcher re-emits them unchanged.

type ContentPart

type ContentPart struct {
	// Type is one of: "text" | "image" | "audio" | "tool_use" | "tool_result".
	// Stable strings; v1 sees no others.
	Type string

	// Text is set when Type == "text".
	Text string

	// MediaType is the MIME of an inline image/audio part, e.g.
	// "image/png" or "audio/mpeg". Empty when the part is URL-only.
	MediaType string

	// DataB64 is base64-encoded bytes for inline media. Empty when
	// the part is URL-only.
	DataB64 string

	// URL is set when the media part references an external URL.
	URL string

	// ToolUse is set when Type == "tool_use" (assistant requests a tool).
	ToolUse *ToolUse

	// ToolResult is set when Type == "tool_result" (Anthropic shape:
	// the tool result lives in the message stream).
	ToolResult *ToolResult

	// Raw is the original part JSON, preserved verbatim. Plugins that
	// want to inspect a part the v1 normalizer didn't decode can read
	// from here; mutating Raw without also updating the typed fields is
	// undefined behavior (the builder uses the typed fields).
	Raw json.RawMessage
}

ContentPart is one fragment of a Message. Type tells the consumer which sibling field carries the payload; unknown types preserve their original JSON in Raw so a plugin can pass them through unchanged.

type Ctor

type Ctor func(cfg map[string]any) (any, error)

Ctor is a builtin factory registered at compile time. cfg is the instance's Config map; the constructor MAY return either a BeforePlugin, an AfterPlugin, or a value implementing both — the Registry detects via type assertion at instance-load time.

Returning a non-nil error from a Ctor is reported by the Registry's Load call as an Init error; the registry returns the partial state and the error, and the caller (proxy main / API handler) decides whether to swap. See spec §4.4.

func LookupBuiltin

func LookupBuiltin(typeName string) (Ctor, bool)

LookupBuiltin returns the Ctor for a type-name. Used by tests and by the future Load() implementation. Returns (nil, false) for unknown types.

type EventClass

type EventClass int

EventClass is the semantic class of an SSE event, computed by the per-protocol classifier.

The classifier is the single line of defense for the spec §10.6 carve-out: tool_use input_json_delta events are a DISTINCT class (ClassToolUseDelta). The dispatcher routes by class, and content transforms only ever see ClassContentDelta / ClassReasoningDelta. Misclassifying a tool-use event as content is the bug that breaks the carve-out; the test suite drives this case explicitly.

const (
	// ClassOther covers events the dispatcher passes through unchanged
	// without inspecting their payload (start/stop markers, pings,
	// unknown event types).
	ClassOther EventClass = iota

	// ClassContentDelta is an event carrying a text fragment of the
	// assistant's content. Anthropic content_block_delta with text_delta,
	// Chat data with delta.content, Responses response.output_text.delta,
	// Gemini text part deltas.
	ClassContentDelta

	// ClassReasoningDelta is an event carrying a text fragment of the
	// model's reasoning / thinking trace. Anthropic content_block_delta
	// with thinking_delta, Responses reasoning_summary_text.delta.
	ClassReasoningDelta

	// ClassToolUseDelta is an event carrying a tool-call argument
	// fragment. Anthropic content_block_delta with input_json_delta,
	// Chat delta.tool_calls[*].function.arguments, Responses
	// response.function_call_arguments.delta.
	//
	// Per spec §10.6, these events are PASSED THROUGH UNTOUCHED by the
	// AFTER-hook dispatcher even when content transforms are registered.
	ClassToolUseDelta

	// ClassTerminal marks the stream-end event. Anthropic message_stop,
	// Chat data:[DONE] (out-of-band — never reaches the dispatcher as
	// an event), Responses response.completed. Pass-through; any
	// remaining buffered last-delta is flushed (with OnLastTextDelta
	// applied) before the terminal event re-emits.
	ClassTerminal

	// ClassContentBlockStop marks the per-content-block terminator.
	// Anthropic content_block_stop, OpenAI Responses
	// response.output_text.done. The dispatcher pairs this with the
	// buffered last text delta of the same block: it runs the
	// OnLastTextDelta chain on the buffered text, re-emits the
	// mutated delta, then emits the stop event. For protocols
	// without a per-block terminator (Chat, Gemini) the EOF flush
	// covers the same case.
	ClassContentBlockStop
)

type Instance

type Instance struct {
	Type    string
	ID      string
	Enabled bool
	Config  map[string]any
	// contains filtered or unexported fields
}

Instance is one running plugin: the configured tuple plus the concrete typed handles. before / after are nil when the plugin instance does not implement that hook side.

func (*Instance) HasAfter

func (i *Instance) HasAfter() bool

HasAfter reports whether this instance implements AfterPlugin. Symmetric to HasBefore; used by the AFTER-side dispatcher entry to avoid wrapping the response body when no plugin would observe it.

func (*Instance) HasBefore

func (i *Instance) HasBefore() bool

HasBefore reports whether this instance implements BeforePlugin. Cheap accessor used by the proxy hot-path "any-BEFORE-plugins?" check so the handler can short-circuit body buffering when nothing needs it.

func (*Instance) Name

func (i *Instance) Name() string

Name returns the type-name (operator-facing identifier). The instance's per-config ID is held on the Instance struct.

type InstanceConfig

type InstanceConfig struct {
	Type    string
	ID      string
	Enabled bool
	Config  map[string]any
}

InstanceConfig is the per-instance config tuple loaded from config.yaml or runtime_overrides.json. The Registry consumes a slice of these (in operator-declared order) and instantiates each.

v2 defines its own type so the package remains independent of the runtime override persistence type.

func Load

func Load(yamlList []InstanceConfig, overrides *OverrideList) []InstanceConfig

Load merges YAML defaults with runtime overrides and returns the effective ordered instance list. See OverrideList for the merge semantics.

Caller pattern (process start and runtime-edit hot swap):

effective := plugin.Load(yamlList, overrides)
reg, errs := plugin.NewRegistry(effective)
if len(errs) > 0 && runtimeEdit { /* do not swap */ }
atomicPtr.Store(reg)

Load itself never errors — it is a pure merge over already-decoded slices. Error surfacing lives in NewRegistry where the Init failures happen.

type InterceptInfo

type InterceptInfo struct {
	Type     string
	ID       string
	Hook     string // "before" | "after"
	Response *InterceptResponse
}

InterceptInfo identifies which plugin instance produced an intercept, alongside the intercept response itself. Callers use Type / ID / Hook to populate trace.PluginIntercepted (spec §5.2) so operators can tell "this 403 came from plugin X" apart from "upstream returned 403".

type InterceptResponse

type InterceptResponse struct {
	Status  int
	Headers http.Header
	Body    []byte
}

InterceptResponse is what a plugin returns when it bypasses the rest of the pipeline. The proxy serializes Body directly to the client (after the AFTER chain runs, in the BEFORE-intercept case). The plugin chooses Status (200, 4xx, 5xx — any HTTP status is legal).

Headers is merged into the response headers; nil is allowed. Body is served as-is; the plugin owns its Content-Type header.

type Message

type Message struct {
	// Role is "user" | "assistant" | "tool". System role is lifted out
	// to ParsedRequest.SystemPrompt and never appears here.
	Role string

	// Content is the ordered list of parts for this turn. A text-only
	// user turn is a single ContentPart{Type:"text"}. Multi-modal
	// content (image + text + image) keeps wire order.
	Content []ContentPart

	// Name is the tool name when Role == "tool" (Chat protocol). Empty
	// otherwise.
	Name string

	// ToolCallID is the matching call id when Role == "tool". Empty
	// otherwise.
	ToolCallID string
}

Message is the normalized turn shape. Built by the request-parser pass that runs BEFORE the hook chain; identical across all four protocols.

"Normalized" means the parser has:

  • lifted system prompts into a separate field (ParsedRequest.SystemPrompt), so Messages contains only user / assistant / tool turns
  • flattened multi-part content blocks into one ordered slice of ContentPart per turn; text parts are exposed as ContentPart{Type:"text"}
  • kept tool calls / tool results as their own ContentPart kinds

type OverrideList

type OverrideList struct {
	Instances []InstanceConfig
}

OverrideList wraps the runtime-override block. The pointer semantics mirror spec §3.3.2:

  • nil OverrideList → no override at all; YAML wins.
  • non-nil with Instances == nil → same as nil (no override).
  • non-nil with Instances == [] → "operator turned all plugins off"; override wins, no plugins run.
  • non-nil with Instances == [..] → full replace of the YAML list.

The distinction between nil and "non-nil empty" is the bug-prone part the spec calls out. Load enforces it explicitly.

type ParsedRequest

type ParsedRequest struct {
	Protocol     Protocol
	Path         string      // e.g. "/v1/chat/completions"
	Method       string      // typically "POST"
	Model        string      // copied from req body
	Messages     []Message   // normalized turns; no system role
	Tools        []Tool      // tool definitions
	SystemPrompt string      // first system role text, computed
	Headers      http.Header // a defensive copy; safe to mutate
	ClientIP     string      // smart-XFF resolved
	KeyHash      string      // matches the trace's KeyHash
	// Streaming is true when the client requested a streaming response
	// (e.g. {"stream": true} body field, or Anthropic SSE accept).
	// Plugins inspect this to know whether AfterContext exposes the
	// streaming callbacks or the buffered Response.
	Streaming bool
	// RawBody is the original request JSON. The build path overlays the
	// typed fields (Model, Messages, Tools, SystemPrompt) onto a map
	// decoded from RawBody, so unknown fields (temperature,
	// response_format, custom keys) survive the roundtrip. Plugins
	// MUST leave RawBody intact when they mutate typed fields; clearing
	// it drops every field the v1 normalizer does not lift.
	RawBody json.RawMessage
}

ParsedRequest is what BEFORE plugins (and AFTER plugins, as the request side) receive. Built from the raw request body by the parser once per request; reused across the entire chain.

Plugins MUST treat ParsedRequest as logically read-only and return a Mutated copy when they want to change something — the proxy may reuse the underlying buffer for retry/logging.

func ParsedRequestFromHTTPRequest

func ParsedRequestFromHTTPRequest(req *http.Request, body []byte) ParsedRequest

ParsedRequestFromHTTPRequest builds a ParsedRequest from a raw inbound *http.Request and its already-read body. The hot-path equivalent of ParsedRequestFromTrace (which builds from a finalized trace.Trace).

The caller MUST have buffered req.Body BEFORE calling this — Body has already been consumed by capture or by the caller's read. body is the raw JSON the parser saw.

Streaming is inferred from the request body's `stream` field (Chat / Messages / Responses) — the inbound HTTP request itself does not carry the streaming bit at the HTTP layer.

Headers are NOT cloned here — the caller passes a snapshot it owns. SystemPrompt / Model / Messages / Tools land from the per-protocol parser dispatchers shared with the trace builder.

func ParsedRequestFromTrace

func ParsedRequestFromTrace(t trace.Trace) ParsedRequest

ParsedRequestFromTrace builds a ParsedRequest from a finalized trace.Trace. The trace's request body must be parsed JSON (trace.Body.Body non-empty); a body that landed in BodyB64 yields a ParsedRequest with empty Messages/Tools — there is nothing to normalize from raw bytes.

Per spec §2.5 the parsed shape is the source of truth; RawBody is the escape hatch for plugins that need original bytes.

type ParsedResponse

type ParsedResponse struct {
	Protocol  Protocol
	Status    int    // upstream HTTP status; 0 when intercepted before forward
	Content   string // concatenated assistant text content
	Reasoning string // concatenated reasoning text (Anthropic thinking, etc.)
	ToolCalls []ToolCall
	Headers   http.Header
	RawBody   json.RawMessage  // non-streaming case
	Events    []sse.Event      // streaming case (buffered post-stream)
	Usage     parser.UsageInfo // reuses parser's pointer-field shape
}

ParsedResponse is what AFTER plugins receive in the non-streaming branch. Built once after the upstream response is fully read.

For streaming responses the framework hands plugins an AfterContext (see hook.go) with per-event semantic callbacks; ParsedResponse is nil in that branch; streaming uses AfterContext with per-event callbacks instead.

func ParsedResponseFromBody

func ParsedResponseFromBody(reqProto Protocol, status int, headers http.Header, body []byte) ParsedResponse

ParsedResponseFromBody builds a ParsedResponse from a non-streaming upstream response body. For streaming responses the AFTER-hook machinery dispatches per-event through StreamDispatcher; this builder is the buffered-branch counterpart.

status / headers come from the *http.Response; body is the bytes the caller read. The protocol discriminator is sourced from the request (not the response) — the response carries no path of its own.

func ParsedResponseFromTrace

func ParsedResponseFromTrace(t trace.Trace) ParsedResponse

ParsedResponseFromTrace builds a ParsedResponse from a finalized trace.Trace. Both streaming (Events) and non-streaming (Body) shapes land in the same returned struct so AFTER plugins that inspect the buffered post-stream view do not have to dispatch themselves.

Usage is reused from parser.ExtractUsage — there is exactly one extractor in the project and the plugin surface does not get to re-implement it.

type Protocol

type Protocol int

Protocol is the parser package's protocol discriminator surfaced to plugins. Plugins switch on this to decide whether the shape is one they understand.

const (
	ProtocolUnknown   Protocol = iota
	ProtocolChat               // OpenAI /v1/chat/completions
	ProtocolMessages           // Anthropic /v1/messages
	ProtocolResponses          // OpenAI /v1/responses
	ProtocolGemini             // Google /v1beta/.../:(stream)generateContent
)

func DetectProtocol

func DetectProtocol(path string) Protocol

DetectProtocol mirrors parser's internal detection. We duplicate the ~6 lines rather than export parser's unexported symbol; W1 must not modify another package per the task contract.

func (Protocol) String

func (p Protocol) String() string

String makes Protocol log-friendly. Stable strings; do not change.

type Registry

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

Registry holds the ordered list of plugin instances and exposes the iteration entry points used by the proxy hot path.

W4.2 hot-reload (spec §3.4 / §4.4): the live instance list is held inside an atomic.Pointer so Reload can swap it without invalidating the outer *Registry that main.go / api.Deps captured at startup. All iteration methods load the current snapshot at call entry; an in-flight call uses the snapshot it loaded, and the next call after a swap picks up the new one. Old instances are NOT Close()d on swap — Close runs only on graceful shutdown (Registry.Close).

func NewRegistry

func NewRegistry(configs []InstanceConfig) (*Registry, []error)

NewRegistry constructs a Registry from an ordered slice of instance configs. Each entry's Type is looked up in the builtin registry; if the type is unknown, that entry is skipped and an error is appended to the returned errs slice. Init failures (factory returns err) are also collected. Other instances continue to load.

Collect-and-continue is the process-start policy: main.go sees the errs slice and decides to log + exit on any failure. The runtime hot- reload path (Reload) uses the same builder but applies all-or-nothing semantics — see Reload for the rollback contract (spec §4.4).

func (*Registry) Close

func (r *Registry) Close() error

Close releases any resources held by registered plugin instances. Plugins that implement io.Closer (or any `Close() error` shape) get their Close called in registration order. Errors are returned as a joined error so the caller (main.go shutdown) can log them without blocking on any single failure. Plugins that don't implement Close are skipped silently — v2 hook plugins are stateless in v1.

func (*Registry) Instances

func (r *Registry) Instances() []*Instance

Instances returns a snapshot of registered instances in registration order. Returned slice is a copy; safe to inspect from any goroutine.

func (*Registry) IterateAfter

func (r *Registry) IterateAfter(ctx context.Context, req *ParsedRequest, ac *AfterContext) *InterceptInfo

IterateAfter runs the AFTER chain in registration order against an AfterContext. See AfterPlugin docs (hook.go) for the per-branch semantics.

Returns InterceptInfo when any plugin intercepts; nil otherwise. ActionMutate replaces ac.Response in the non-streaming branch.

func (*Registry) IterateBefore

func (r *Registry) IterateBefore(ctx context.Context, req *ParsedRequest) (*ParsedRequest, *InterceptInfo)

IterateBefore runs the BEFORE chain in registration order.

Semantics (spec §2.2 / §2.4 / §4):

  • Plugins not implementing BeforePlugin are skipped.
  • Disabled instances are skipped.
  • ActionContinue: pass current request to next plugin.
  • ActionMutate: replace current request with Mutated (when non-nil); pass new request to next plugin.
  • ActionIntercept: STOP iteration; return (current request, intercept info). Caller serves the intercept body and runs the AFTER chain on it.

Panics are recovered by safeBefore (recover.go) with the request flowing unchanged.

func (*Registry) Reload

func (r *Registry) Reload(yamlList []InstanceConfig, overrides *OverrideList) error

Reload atomically swaps the registry's live instance list to a fresh build of (yamlList × overrides). All-or-nothing: any builder error returns a joined error and leaves the OLD snapshot live. In-flight requests on the old snapshot finish naturally; Close on old instances is the responsibility of graceful shutdown, NOT the swap (spec §4.4).

This is the entry point the API handlers invoke after persisting a config edit; the handler rolls back the persisted file when Reload returns an error. main.go does NOT need to call Reload at startup — NewRegistry already seeds the initial snapshot.

Snapshot granularity is per iterate call. Within a single in-flight proxy request the BEFORE and AFTER chains load the snapshot independently, so a Reload landing between them means BEFORE runs on the old config and AFTER on the new. This is intentional (spec: each iterate call loads the freshest instances at entry); per-request snapshot consistency would require threading a snapshot through ctx and is out of scope.

func (*Registry) WrapStreamDispatcher

func (r *Registry) WrapStreamDispatcher(ctx context.Context, req *ParsedRequest) (*StreamDispatcher, *InterceptInfo)

WrapStreamDispatcher returns a StreamDispatcher seeded with the registered AFTER plugins' callbacks for the given request.

Order of operations:

  1. Build a fresh AfterContext.
  2. Call IterateAfter(req, ac) so each AFTER plugin registers its callbacks (and the dispatcher records any panics).
  3. Return a StreamDispatcher referencing the populated context.

The caller's stream loop then calls Dispatcher.Process(ev) per upstream event.

When an AFTER plugin intercepts during registration, the returned dispatcher is nil and the InterceptInfo is non-nil: the caller should drop the upstream stream and serve the intercept body.

type RouteMatcher

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

RouteMatcher is a precompiled list of route patterns. Identical semantics to internal/plugin/builtin/pathfilter:

  • empty input list: match every path.
  • exact: pattern equals path.
  • trailing "*": strip the star, HasPrefix against the path.
  • lone "*" or empty list: match every path.

Built once at plugin Init via CompileRoutes; Matches is called per request, allocation-free.

func CompileRoutes

func CompileRoutes(raw []string) (RouteMatcher, error)

CompileRoutes parses an operator-supplied pattern list. Empty / nil input matches all paths. Empty-string entries are rejected so a YAML typo surfaces at Init rather than silently match everything.

func (RouteMatcher) Matches

func (rm RouteMatcher) Matches(path string) bool

Matches reports whether path is covered by the compiled list.

type StreamDispatcher

type StreamDispatcher struct {
	Protocol Protocol
	After    *AfterContext
	// contains filtered or unexported fields
}

StreamDispatcher applies a chain of registered content/reasoning transforms (held on an AfterContext) to an event stream, in order, and produces the re-emitted event stream.

Contract:

  • Tool-use deltas (ClassToolUseDelta) PASS THROUGH UNTOUCHED even when content transforms are registered. This is the spec §10.6 carve-out enforced at the dispatcher level.
  • Content / reasoning deltas have each registered transform applied in registration order; the final text is written back into the event via RewriteDeltaText.
  • One event of lookahead per content block: the dispatcher holds the most recent content_delta of each block index, flushing it when (a) the next content_delta for the same block arrives, (b) a content_block_stop for that block arrives — in which case the OnLastTextDelta chain runs first — or (c) the stream EOF flush fires.
  • Per-block stops (ClassContentBlockStop) emit the buffered last delta with OnLastTextDelta applied, then the stop event itself. Append-to-last-delta produces a wire-valid sequence ending with the protocol's terminator, not a synthesized block after it.
  • Per-event Process is the synchronous primitive. WrapStream is the channel wrapper that calls Process and FlushBeforeFinish on EOF.

func (*StreamDispatcher) FlushBeforeFinish

func (d *StreamDispatcher) FlushBeforeFinish() []sse.Event

FlushBeforeFinish runs any pending last-delta buffers through the OnLastTextDelta chain (so suffix-style mutations land at EOF for protocols without a per-block terminator, notably Chat) and returns the resulting events.

The R1 amendment changed the semantics of this function. Previously it invoked deprecated EmitBeforeFinish callbacks to synthesize new content blocks AFTER the protocol terminator (which produced a wire-invalid sequence). The new behavior flushes the buffered last-delta of each block in place; callers no longer need to hold the terminal event.

Calling FlushBeforeFinish twice yields nothing on the second call (the buffer is drained).

func (*StreamDispatcher) Process

func (d *StreamDispatcher) Process(ev sse.Event) []sse.Event

Process classifies one event, applies registered transforms, and returns the events to re-emit. Length depends on the event class: content_delta usually returns 0 (buffered) or 1 (the previously buffered delta flushed in front of the new one); content_block_stop returns up to 2 (flushed-last-delta + stop); other classes return 1.

func (*StreamDispatcher) WrapStream

func (d *StreamDispatcher) WrapStream(upstream <-chan sse.Event) <-chan sse.Event

WrapStream is the channel-form integration of the dispatcher.

It reads from upstream until that channel is closed, applies per- event transforms, and writes the re-emitted events to the returned channel. On upstream close it runs FlushBeforeFinish — flushing any remaining buffered last-delta — BEFORE closing the output channel.

The output channel buffer matches the input buffer size. The caller owns the input channel (closes it on upstream EOF or context cancel); the wrapper owns the output channel (closes it after flushing).

Three correctness invariants:

  1. OnLastTextDelta callbacks fire on per-block terminators (content_block_stop / response.output_text.done) when the upstream emits one, and on EOF otherwise — uniform behavior across all four protocols including Chat (no terminal event at all).

  2. Tool-use deltas pass through untouched at every stage; the dispatcher never inspects nor mutates their payloads.

  3. No event is emitted after the protocol's stream terminator (message_stop, response.completed) — the last-delta mutation happens BEFORE the terminator flows through Process.

type Tool

type Tool struct {
	Name        string
	Description string
	// Schema is the JSON Schema for the tool's arguments object.
	Schema json.RawMessage
	// Raw is the original tool definition JSON (escape hatch for
	// protocol-specific fields the normalizer didn't lift).
	Raw json.RawMessage
}

Tool is one tool definition the client offered to the model.

type ToolCall

type ToolCall struct {
	ID        string
	Name      string
	Arguments string
}

ToolCall is a structured tool invocation on the response side (post-stream buffered view). Distinct from ToolUse, which is the request-side message-part view.

AFTER plugins may READ ToolCall for inspection in v1, but mutating it is undefined behavior — the dispatcher does not re-emit modified tool-call arguments. See spec §10.6.

type ToolResult

type ToolResult struct {
	ToolUseID string
	Content   string
	IsError   bool
}

ToolResult is the result of a tool invocation, sent back as a user- or tool-role message in the next turn.

type ToolUse

type ToolUse struct {
	ID    string
	Name  string
	Input json.RawMessage
}

ToolUse is an assistant-side request to call a tool. Lives on a Message ContentPart with Type == "tool_use".

Directories

Path Synopsis
builtin
textappend
Package textappend implements the text-append plugin described in docs/specs/plugin-b-c-spec.md, using append-to-last-delta for streaming responses.
Package textappend implements the text-append plugin described in docs/specs/plugin-b-c-spec.md, using append-to-last-delta for streaming responses.
textreplace
Package textreplace implements the text-replace plugin described in docs/specs/plugin-b-c-spec.md.
Package textreplace implements the text-replace plugin described in docs/specs/plugin-b-c-spec.md.

Jump to

Keyboard shortcuts

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