Overview
A plugin is a Go value that implementspluginapi.Plugin plus one or more
optional hook interfaces. Every guardrail is an
instance of a plugin, and so is a custom routing strategy for a
virtual model. There is one contract, in the
github.com/enterpilot/gomodel/pluginapi package, and three ways to ship a
plugin:
Whichever way it arrives, a plugin type shows up under its manifest name on
the dashboard’s Guardrails page, in
GET /admin/plugins, and as a type in
guardrails.rules.
pluginapi depends on the standard library only. A plugin imports it and
nothing else from GoModel.
Phases
A plugin declares which hooks it implements inManifest.Kinds. GoModel runs
the content phases from the matched workflow:
Which instance runs in which phase, and in what order, is a workflow decision
(
steps[].phase and steps[].step). The same instance may appear once per
phase.
Two more hook kinds exist in the contract but are not run by this release:
request (before model resolution) and complete (after the client response
is written). A manifest may declare them; GoModel validates that the
interfaces are implemented and ignores them at runtime. The route kind is
used by virtual models, see Routing strategy plugins.
Phases apply to /v1/chat/completions, /v1/responses, and /v1/messages.
Anthropic requests are translated to the chat form before any hook runs, so a
plugin sees one shape; Meta.Dialect reports anthropic_messages when it
matters. Inline /v1/batches items go through the prompt phase when
guardrails.enable_for_batch_processing is on.
Chain execution
Within one phase, instances are grouped bystep:
- Steps run in ascending order. A later step sees the edits of an earlier one.
- Within a step, instances whose manifest says
Mutates: falserun concurrently on a shallow copy of the exchange (theirValuesand response headers are merged back). The step may hold at most one instance withMutates: true, which runs after the readers. Two mutating instances at the same step is a configuration error. - Decisions of a step merge by severity:
block>respond>warn>allow. The first blocking decision ends the chain after its step.
Decisions
Every hook returns apluginapi.Decision:
respond is the “block with a safe message” most guardrail products offer:
agent loops keep running instead of surfacing a 4xx. Use block when the
caller should see an error.
Detail is stored in the audit record and must not contain secrets.
NoStore on any decision keeps the response out of the
response cache, exact and semantic, so the next request
with the same or a similar body runs the plugins again instead of replaying
a stored answer. Set it when the reply carries request-specific data a plugin
restores on the way out, for example a de-anonymizer that puts the caller’s
PII back into the assistant text: a cached copy would hand that text to
whoever sends a matching prompt next. It is honoured from any phase and any
instance of the request, whatever the merged decision was.
Audit trail
Every instance that ran leaves an outcome in the audit entry’sdata.guardrails list, in execution order across the prompt, response and
stream phases, silent allows included:
A configured step without an outcome did not run: an earlier block or answer
ended the request, or a cache hit skipped the model call. The dashboard
colors the workflow chart of an audit entry from this list, and its
“Guardrails” tab lists the outcomes. The list is the decision trail; the
request revisions below are the body trail.
Prompt-phase decisions are also appended to the request’s revision chain (the same
place request rewrites are recorded), one entry per instance in step order.
An instance that edited the prompt is a changed revision carrying the request
as it stood right after that step, sizes included, so a chain of edits reads
step by step and the last one is what was forwarded; the request bodies are
stored under the same
LOGGING_LOG_BODIES and LOGGING_LOG_REVISION_BODIES
gates as request rewrites. Each editing step leaves only a copy of the prompt
behind on the request path; applying those copies to the request and encoding
the bodies runs afterwards, alongside the provider call.
LOGGING_LOG_GUARDRAIL_STEPS=false skips them and records a chain’s edits as
one revision carrying the request as forwarded. A warning, block, answer or failure without an edit is a no-change
entry carrying the decision; a silent allow leaves no revision. Response and
stream decisions are recorded in data.guardrails only, and the objections
among them are logged with the request id.
Fail modes and timeouts
Each instance has afail_mode and a timeout_ms:
The instance name of a fail-closed failure goes to the logs and the audit
record, never to the client. Panics inside a plugin are recovered and
treated as errors.
Timeouts are enforced at the deadline. A hook receives a context that
ends at
timeout_ms (and when the client disconnects); GoModel stops
waiting at that moment whether or not the hook has returned. A hook that
ignores its context keeps running in the background with no effect on the
request, so honour ctx.Done() in anything that blocks. An abandoned hook
counts as a failure. It fails open only when it could not touch what the
request continues with: a non-mutating hook runs on its own copy of the
exchange, which is discarded. A mutating hook, or an in-flight stream hook,
that outlives its deadline always fails the request, even with
fail_mode: open, because its edits can no longer be trusted. Init has a
fixed 10 s deadline handled the same way: an Init that has not returned by
then fails the instance.
Keep the default. Switch an instance to open only when it is an observer
whose absence is acceptable (a header tagger, a warn-only classifier).
Instance lifecycle
GoModel builds one plugin value per guardrail definition and callsInit
once. Definitions are reloaded from storage periodically
(workflows.refresh_interval, default 1 minute) and on every admin change;
an instance whose type, config, user_path, fail_mode, and timeout_ms
are unchanged survives the reload, so state a plugin accumulates (counters,
caches, connections) is kept. A replaced or deleted instance is retired and
closed later, no sooner than two refresh intervals after retirement and only
once nothing holds it: compiled workflows hold their instances until the
workflow snapshot drops them, and requests hold theirs for the duration of a
phase (a long stream included). A hook is never called after Close; a call
that reaches a closed instance fails under the instance’s fail_mode
instead. Every instance is closed on shutdown.
Streaming
Bytes already sent to the client cannot be recalled, so a stream plugin picks one of three modes inStreamHook.StreamPolicy():
Lookbehind (
transform only): GoModel withholds the last LookbehindChars
characters per window and presents each delta together with that tail, so a
pattern spanning two chunks (a phone number, an API key) is visible in one
event before any of it reaches the client. A choice’s text is one window and
the arguments of each of its tool calls (StreamEvent.Call) another, so a
placeholder split across two argument deltas is rewritten the same way. A
delta of another kind for the same choice flushes that choice’s windows of
other kinds first, so text and tool calls keep their order, while parallel
tool calls stream independently. A pattern of up to
LookbehindChars + 1 characters is always caught. 0 disables it. The tail
comes back already carrying the plugin’s earlier edits, and
StreamEvent.Overlap says how many leading characters it spans: a plugin
must only edit matches that extend past Overlap, since a match inside it
was handled the previous time (the built-in string_replace does this, so
a rule like a => aa is applied once, not on its own output). A match that
touches the end of the window may still grow with the next delta, so a plugin
can leave it unedited: the tail shows it again next time. StreamEvent.Final
marks the last event of a window (the stream ended or a delta of another kind
flushed it), after which nothing is withheld, so an edit put off that way is
due then. string_replace does this for matches that fit in its lookbehind,
so an open-ended pattern covers the whole key. A chat chunk
that carries text together with finish_reason or usage is re-segmented
too; those members go out once, with the chunk’s last text, or on a chunk
with empty text when the plugin removed that text.
Coalescing (transform only): a hook whose per-call cost is high, such
as a classifier or a named-entity detector calling a sidecar, cannot afford a
call per token. MinChunkChars makes GoModel collect the text deltas of a
choice until at least that many new characters are pending and present them
as one text event; a non-text event and the end of the stream flush a shorter
run. It composes with lookbehind: the withheld tail leads the window and
Overlap still counts it, so the same edit contract applies. The client
waits for up to MinChunkChars characters at a time, and the largest value
among the transform instances of a stream applies to all of them, capped at
16384 characters. 0, the
default, presents deltas as they arrive.
Chat chunks with several choices are split into one chunk per choice
before plugins see them, so every choice is inspected; provider usage
stays on the last of them.
Restated text (Responses API): after a delta was replaced or dropped,
the events that repeat a part’s full text (response.output_text.done,
response.content_part.done, response.output_item.done, and the terminal
response.completed) are rewritten to the text that was actually emitted,
so clients reading the completion instead of the deltas see the same result.
Event size (transform and observe): one SSE event larger than 4 MiB
cannot be inspected in flight, so the stream ends fail-closed with code
event_too_large rather than relaying it past the plugins. Comments and
keep-alive lines are never affected.
Buffering: the upstream is drained into a bounded buffer
(MaxBufferBytes, default 4 MiB; exceeding it fails closed with code
response_too_large). The buffer is shared by every plugin buffering the
stream and by the response chain, so the largest cap asked for applies, and
the default whenever the response chain runs or a plugin sets no cap. While draining, GoModel sends the SSE comment
: gomodel-buffering every 15 s so proxies and clients do not time out; SDKs
ignore comment lines. After the response chain runs, the original bytes are
replayed unchanged (allow, warn), a stream is synthesized from the edited
completion (allow with edits, respond), or a single error chunk is sent
(block).
Mixed chains: if any stream instance asks for buffer, or the workflow has
any response step, the whole stream is buffered and transform instances run
over the replay.
Warnings on streams: when response or stream plugins run, GoModel commits
the HTTP headers with the first bytes of the body rather than up front, so a
warn decided over a buffered response reaches the client as the
X-GoModel-Guardrail header as long as buffering finished before the first
keep-alive comment (15 s). A warn decided later (in OnStreamEnd of a
transform stream, or after a keep-alive went out) cannot change headers
already sent; it is still recorded in the audit trail and the logs.
Cutting a stream: a terminate decision in transform mode, or a
block/respond from OnStreamEnd, ends the stream with
finish_reason: "content_filter" (stop_reason: "end_turn" for Anthropic)
followed by [DONE]. The client keeps what it already received. If nothing
may leak before the decision, the plugin must use buffer mode; the built-in
string_replace and llm_judge do exactly that for block and respond.
The Exchange
Every hook receives one*pluginapi.Exchange, the same object in every phase
of a request:
Edits go through methods, not field assignment, so GoModel re-encodes only
what changed and untouched messages keep every provider-specific field
(
cache_control, multi-part content, extra fields). Removing a message that
carries a tool call whose result is still present (or the reverse) returns a
DanglingToolError naming the partner message; the request is rejected if
the pair stays broken.
A plugin that scans or rewrites text does not need to walk parts itself:
Prompt.TextTargets(roles...) returns one TextTarget per text part (plain
text, and text inside tool results) with the message ID, role, part indexes,
and current text, and Prompt.SetTargetText(target, text) writes the edit
back. Completion.TextTargets() and Completion.SetTargetText do the same
for the choices of a response. The built-in string_replace and
llm_based_altering are written this way.
Media can be edited too. Part.DecodeMedia() returns the inline bytes and
media type of an image sent as a data URI or of chat input audio, and
Prompt.SetMedia(msgID, partIdx, mediaType, data) replaces the payload of an
image or audio part: the part goes back out as a data URI (images) or base64
input audio, and the other members of the wire part, such as an image’s
detail, are kept. An image referenced by URL has nothing inline to decode;
a plugin that wants to redact it fetches it itself. Media inside a tool
result is replaced with SetToolResult.
Meta.Cache.PlannedPrefixMessages tells a prompt plugin how many leading
messages the provider cache planner will mark as the cached prefix. Appending
keeps the cache; editing inside the prefix invalidates it for the session.
Configuration
Enabling the plugin system
.so loading, no guardrails,
and no routing-strategy plugins (a virtual model with strategy: plugin is
rejected). GET /admin/plugins and the /admin/guardrails endpoints answer
503 with code feature_unavailable, and the dashboard hides the Plugins & Guardrails
page. Guardrails are plugin instances, so GUARDRAILS_ENABLED=true turns the
plugin system on implicitly; set PLUGINS_ENABLED=true on its own to use
routing-strategy plugins or manage guardrail definitions without running them.
Instances
Instances are declared inguardrails.rules or created on the dashboard;
both land in the same store. See Guardrails for the
per-type settings.
config.yaml are seeded into the managed default workflow at
order in phase. type accepts an optional plugin: prefix.
Loading .so files
file must resolve inside one of the search_paths (symlinks are
followed and checked). A file that cannot be resolved, verified, or opened is
a startup error naming the file.
GUARDRAILS_ENABLED remains the switch that decides whether configured
instances run on traffic.
Dashboard
- Plugins & Guardrails page: Create Guardrail lists every plugin that implements a
prompt,response, orstreamhook. The form is generated from the plugin’sConfigSchema:text,textarea,number,select,checkboxes,bool(a toggle),list(free-text entries, one per line, stored as a string array),secret(masked;GETreturns********and sending that literal back keeps the stored value), andmodel(the same searchable model picker as the playground and virtual-model editors, listing the gateway’s models; an alias or virtual model is typed in). The Advanced section holds Fail mode (Default, Closed, Open) and Timeout (ms); they apply to the instance in every workflow that references it. - Plugins list: at the bottom of the Plugins & Guardrails page, every
loaded plugin type with its title and name (
Header Editthenheader_edit, the name being what configuration refers to), version, hooks, source (built-in, registered, or the.sopath), and health. Guardrail plugins (those whose instances apply a policy to prompts, responses, or streams) carry a shield and are listed first; routing strategies are plugins too, but not guardrails. A plugin that failed to load is listed with its error. The page is shown whenever the plugin system is on. - Workflows page: each step has a Phase selector; the instance dropdown only offers instances whose plugin implements that phase.
Shared-object plugins
Go’splugin package sets the rules, and they are strict:
- Platform: Linux, macOS, and FreeBSD, and only in a binary built with
CGO_ENABLED=1. The default GoModel binary and image are static and refuse.sofiles with an error that says so. Usemake build-plugins(bin/gomodel-plugins) or thegomodel:<version>-pluginsimage (make image-plugins, built fromDockerfile.plugins, glibc runtime). - Exact toolchain: the plugin must be built with the same Go version,
the same build flags (
-trimpath,-race,-tags), and identical sources of every shared package. Only the standard library andpluginapiare shared, so internal GoModel changes never affect a plugin, but every GoModel release and every Go toolchain update (patch releases included) needs a rebuild. Make it a CI step. - No unload: a
.sostays loaded until restart. Changing a loaded file takes effect on restart; editing an instance’s config re-runsIniton a fresh value without a restart. - Trusted code: loading a
.sois equivalent to changing the binary. Keepsearch_pathsroot-owned and pinsha256in production.
Build and inspect
plugin build runs go build -buildmode=plugin with the flags recorded in
the gomodel binary that runs it, forces CGO_ENABLED=1, pins GOTOOLCHAIN
to the host’s Go version, stamps a GoModelBuildInfo variable into the
plugin, and refuses an output whose Go version differs from the host’s.
Always build with the binary that will load the plugin. plugin inspect
opens the file and prints its manifest, config schema, and build info. A
refused load names both sides:
plugin-builder target of Dockerfile.plugins and run
it against your plugin directory:
gomodel --version prints the Go and pluginapi versions of the binary.
docs/example_plugins/README.md covers building a plugin in its own Go module.
Writing a plugin
Apackage main that exports func GoModelPlugin() pluginapi.Plugin is a
shared-object plugin; the same type registered with ext.RegisterPlugin is a
compiled-in one. This one blocks a request when the last user message
contains a configured word:
Initreceives the config after validation againstConfigSchema: defaults applied, numbers, booleans, and lists coerced, unknown keys rejected, required keys checked. Line-orientedtextareafields accept a YAML list of strings as well as a block scalar;listfields accept a list or one comma-separated line and arrive as a JSON array;boolfields accepttrue/falseas booleans or text and arrive as a JSON boolean. It is called once per configured instance and again when the instance is edited.- Read the config with
pluginapi.ParseConfigand its typed readers (String,Choice,Bool,Int,Float,OptionalFloat,List,Lines,Roles,BlockStatus) instead of a struct: each reader applies the same coercions the dashboard and config.yaml need, returns its default on a problem, andErr()reports the first one with the key named, so a decoder is a straight list of assignments and one check.RoleOptions()is the checkbox listRolesunderstands. - Render findings through
pluginapi.Enforcement: readaction,message,block_status, and optionallyrespond_textinto it, thenEnforce(code, detail)gives the block error, the respond completion, or the warning, andReject(code, detail)gives block or respond even when the action is warn (for a finding that must not pass).BlockStatusField()is the conventionalblock_statusfield. The built-in guardrails share this, so their block, respond, and warn behave alike. - Export a constructor (
func GoModelPlugin() pluginapi.Plugin) so one file can back several instances. Avar GoModelPlugin pluginapi.Pluginworks too but limits the file to a single instance. - Set
Mutates: truewhen the plugin editsPrompt,Response, or the stream. Non-mutating plugins may share a step and run concurrently. - Set
Guardrail: truewhen the plugin’s instances are guardrails: policies applied to prompts, responses, or streams, whether they block, answer, warn, or rewrite. Every built-in traffic plugin sets it (llm_judge,string_replace,header_edit,system_prompt,llm_based_altering); a routing strategy such ascheapest_healthynever touches traffic and leaves it false. The runtime ignores the flag; the dashboard marks guardrail plugins and their instances with a shield and lists them first. - The
Hostpassed toInitoffersLogger()(pre-tagged with plugin and instance),Inference().Complete(...)(a chat completion through the gateway with originplugin, scoped to<user path>/guardrails/<instance>for budgets and audit),Metrics()(names prefixedplugin_<name>_), andHTTPClient()for calls to external services such as a classifier or a PII detector. The client is shared by every instance, uses the gateway’s proxy environment and connection pool, and has a 60 s timeout as a backstop; build requests withhttp.NewRequestWithContextso the instancetimeout_msbounds each call.History()returns an error in this release. - Add
Summarize(config json.RawMessage) stringto render a one-line summary in the guardrails list, andNormalize(config) (json.RawMessage, error)to canonicalize a config before it is stored. Both are optional. - Implement
pluginapi.HealthChecker(Health(ctx) error) when an instance depends on something outside the process, such as a classifier sidecar. GoModel probes it off the request path once the instance is built and on every guardrail refresh (workflows.refresh_interval, one minute by default), with a 5 s deadline. A failing probe marks the instancedegradedinGET /admin/guardrailsand on the dashboard, with the error as the reason, and is logged once per transition; the error text must not contain secrets and is truncated to 256 characters. Health is informational: traffic still runs through the instance under itsfail_mode. - Field inputs
secretandmodelexist for API keys of external classifiers and for model pickers; asecretvalue is masked in every admin response. Useboolfor a switch andlistfor an open-ended list of strings (entity types, an allow list) instead of encoding them in aselector atextarea. - Add
Scope: pluginapi.ScopeRouteto fields that belong to a virtual model rather than to the instance (routing strategies only).
docs/example_plugins/keywordblock/main.go is a fuller, commented example with
prompt and response hooks, and the built-ins under
internal/plugins/builtin/ are reference implementations of every hook kind.
Testing a plugin
pluginapi/plugintest tests a plugin without GoModel:
plugintest.NewHost(replies...)is a fakeHost: scripted inference replies (or aReplyfunction, or anErr), recordedRequests(), recordedRecorded()metrics, and a settable HTTP client for a fake sidecar started withhttptest.plugintest.Init,Text,Prompt,Completion, andExchangebuild the fixtures a hook call needs, with clean change tracking and a Values bag.plugintest.RunStream(ctx, hook, x, events)drives aStreamHookthe way the host does under itsStreamPolicy: chunk coalescing, the withheld lookbehind tail shown again withOverlap, pass, replace, drop, and terminate applied to the whole window, and the buffered mode assembling the completion forOnResponse. The result holds the text a client would have received per choice, the delivered events, the terminate decision, and the end decision.
string_replace tests, where New is that plugin’s constructor:
x.Response yourself before calling RunStream when the hook
must see tool calls, usage, or a finish reason.
Routing strategy plugins
A plugin that implementspluginapi.RouteStrategy is a load-balancing
strategy for virtual models:
RouteRequest carries the virtual model Source, the Candidates in
configured order (provider, model, provider/model, weight, and prices per
million tokens when known), the session id and sticky SessionTarget,
Meta, and the virtual model’s strategy_config as JSON (Prompt is nil
in this release). Select returns the chosen Qualified target and an
optional Reason (debug logs only); OnAttemptEnd reports success, status,
latency, and timeouts so the strategy can adapt. Failover chains, capacity
probes, and session pinning stay GoModel’s job.
Select the strategy on the virtual model:
strategy_configis validated against the plugin’s route-scoped fields (route_fieldsinGET /admin/plugins).strategy: pluginwithoutstrategy_pluginis a validation error, andPUT /admin/virtual-modelsrejects a name that is not a loaded plugin with aroutehook.- Target
weightis ignored under the plugin strategy. - Instance-scoped fields of a route plugin (an endpoint, an API key) come
from a guardrail definition whose name and type both equal the plugin
name; without one the plugin is initialized with
{}. Instance-scopedsecretvalues currently reach a route plugin redacted. Saving that definition rebuilds the instance; the previous one is closed once its in-flightSelectcalls return. - GoModel falls back to weighted round robin, logging one warning per
virtual model, when the plugin is missing, is not a route plugin, failed to
initialize,
strategy_configis invalid, orSelecterrors, panics, takes longer than 250 ms, or returns a target outside the viable pool.
plugin:<name> entry per loaded route plugin (VIRTUAL_MODEL_STRATEGIES in
the runtime config is round_robin,cost,failover[,adaptive],plugin:<name>,...);
choosing it renders the plugin’s route fields under the targets table.
cheapest_healthy is the built-in reference strategy
(internal/plugins/builtin/routeexample). It keeps the last 50 outcomes per
target and picks the cheapest (prefer: cheapest, the default) or the
lowest-median-latency (prefer: fastest) target whose error rate is at or
below max_error_rate (default 0.2), keeps a healthy SessionTarget, and
considers every candidate when none is healthy.
Admin endpoints
All endpoints require the same admin credentials as the rest of/admin.
GET /admin/plugins
Every plugin type known to the gateway, including ones that failed to load:source is builtin, registered (compiled in through ext), or the
absolute path of the .so. fields are the instance-scoped schema fields,
route_fields the route-scoped ones. A .so built with gomodel plugin build also reports built_with (Go and pluginapi versions).
GET /admin/guardrails/types
One entry per plugin that implements aprompt, response, or stream
hook, in the shape the guardrail editor renders:
phases, source, mutates, and guardrail are new (instance views from
GET /admin/guardrails carry guardrail as well); fields[].input may now be
secret, model, bool, or list, and fields[].scope is "" (instance)
or "route".
GET, PUT, DELETE /admin/guardrails
Definitions gainfail_mode (closed | open, empty for the phase default)
and timeout_ms (0 for none); views additionally carry phases (from the
plugin manifest), summary, and health (ok | degraded, with
health_error and health_checked_at for plugins that implement a health
probe). PUT accepts both new fields. A stored
secret value is returned as ********; send it back unchanged to keep it,
or "" to clear it.
GET /admin/workflows/guardrails
The instances available to workflow steps, with the phases each supports:Workflow payload version 2
POST /admin/workflows accepts schema_version: 2 with steps[] carrying a
phase; version 1 payloads (guardrails: [{ref, step}]) stay valid and are
returned unchanged. Workflow views expose the per-phase chain hashes as
chain_hashes. See Workflows.