Documentation
¶
Overview ¶
Package v2 is the BUILD-phase hot-path plugin contract described in uiux-research/plugin-b-c-spec.md.
It coexists with the Phase A observe-class scaffold in github.com/leoyun/api-log/internal/plugin. The Phase A package is not touched by W1; W6 migrates it. Until then, both packages are buildable independent surfaces.
The v2 surface is the surviving BEFORE / AFTER hook class ratified by operator 2026-05-30. The "interfere class" is bounded by two call sites (post-receive / pre-forward and post-upstream / pre-client-send), honored fail-open per spec §4, and recorded post-mutation only per spec §5. v1 does NOT carry tool_call argument mutation on the AFTER hook (spec §10.6 carve-out); streaming tool_use input_json_delta events pass through untouched.
Index ¶
- func BuildRequestBody(req *ParsedRequest) ([]byte, error)
- func BuildResponseBody(resp *ParsedResponse) ([]byte, error)
- func IsSSEContentType(ct string) bool
- func MatchRoute(routes []string, path string) bool
- func RegisterBuiltin(typeName string, ctor Ctor)
- func ResetBuiltinsForTest()
- func RewriteDeltaText(proto Protocol, classified ClassifiedEvent, newText string) (sse.Event, error)
- func SynthesizeContentDelta(proto Protocol, text string) (sse.Event, error)
- type Action
- type AfterContext
- func (ac *AfterContext) BeforeFinishCallbacks() []func(emit func(text string))deprecated
- func (ac *AfterContext) ContentDeltaTransforms() []func(string) string
- func (ac *AfterContext) EmitBeforeFinish(fn func(emit func(text string)))deprecated
- func (ac *AfterContext) LastTextDeltaCallbacks() []func(blockIndex int, text string) string
- func (ac *AfterContext) OnContentDelta(fn func(text string) string)
- func (ac *AfterContext) OnLastTextDelta(fn func(blockIndex int, text string) string)
- func (ac *AfterContext) OnReasoningDelta(fn func(text string) string)
- func (ac *AfterContext) ReasoningDeltaTransforms() []func(string) string
- type AfterPlugin
- type AfterResult
- type BeforePlugin
- type BeforeResult
- type ClassifiedEvent
- type ContentPart
- type Ctor
- type EventClass
- type Instance
- type InstanceConfig
- type InterceptInfo
- type InterceptResponse
- type Message
- type OverrideList
- type ParsedRequest
- type ParsedResponse
- type Protocol
- type Registry
- func (r *Registry) Close() error
- func (r *Registry) Instances() []*Instance
- func (r *Registry) IterateAfter(ctx context.Context, req *ParsedRequest, ac *AfterContext) *InterceptInfo
- func (r *Registry) IterateBefore(ctx context.Context, req *ParsedRequest) (*ParsedRequest, *InterceptInfo)
- func (r *Registry) Reload(yamlList []InstanceConfig, overrides *OverrideList) error
- func (r *Registry) WrapStreamDispatcher(ctx context.Context, req *ParsedRequest) (*StreamDispatcher, *InterceptInfo)
- type RouteMatcher
- type StreamDispatcher
- type Tool
- type ToolCall
- type ToolResult
- type ToolUse
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 ¶
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 ¶
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 ¶
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 ¶
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 docstring for the migration rationale (R1 amendment 2026-05-30).
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 ¶
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 ¶
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 ¶
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.
type InstanceConfig ¶
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.
W3 wires this to internal/runtime.PluginsOverrides. v2 defines its own type so the package builds independently of the overrides extension (which lands in a later WP).
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 ¶
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 until W1+W3 wire the buffered-streaming path.
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.
func DetectProtocol ¶
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.
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 ¶
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 ¶
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:
- Build a fresh AfterContext.
- Call IterateAfter(req, ac) so each AFTER plugin registers its callbacks (and the dispatcher records any panics).
- 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. This is the structural fix for the R1 amendment: 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:
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).
Tool-use deltas pass through untouched at every stage; the dispatcher never inspects nor mutates their payloads.
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 ¶
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 ¶
ToolResult is the result of a tool invocation, sent back as a user- or tool-role message in the next turn.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
builtin
|
|
|
textappend
Package textappend implements the BUILD-phase "text-append" plugin described in uiux-research/plugin-b-c-spec.md §7.2, with the R1 streaming redesign (append-to-last-delta, not new-block-synthesis).
|
Package textappend implements the BUILD-phase "text-append" plugin described in uiux-research/plugin-b-c-spec.md §7.2, with the R1 streaming redesign (append-to-last-delta, not new-block-synthesis). |
|
textreplace
Package textreplace implements the BUILD-phase "text-replace" plugin described in uiux-research/plugin-b-c-spec.md §7.1.
|
Package textreplace implements the BUILD-phase "text-replace" plugin described in uiux-research/plugin-b-c-spec.md §7.1. |