Skip to content

Releases

v0.65.2: Third Time Lucky

Choose a tag to compare

@github-actions github-actions released this 11 Nov 13:13
f5cf932

Changed

  • Runs on OpenCode 2.2.7 (#646). The host peer range is now >=2.0.26 <2.3, and the optional effect peer accepts 4.1.0. This closes the 2.2.6 and 2.2.7 load-check failures (#622, #647).
  • Dependencies. About 40 routine bumps merged, covering the ai SDK, @opentui/core, conventional-commits-parser, fast-xml-parser, @ai-sdk/openai-compatible, GitHub Actions, and the example apps. No runtime behaviour change.

Fixed

  • Anthropic usage speed for SDK 1.3. The Messages response carries the usage speed field the 1.3 SDK requires (ddc8245).
  • The 0.65.0 and 0.65.1 releases never reached npm. 0.65.0 was not tagged: its merge CI failed on a racing test, fixed in #651. 0.65.1 was tagged, but its Publish run failed at "Build the release notes" because the changelog's previous heading (0.65.0) had no tag, so npm kept 0.64.0. This patch carries their changes. Its release notes now find the previous tag, v0.65.1.

The full story

1 commit since v0.65.1, grouped by type and oldest first. Each entry is the commit message as written.

Docs (1)

docs(changelog): write the 0.65.2 notes; 0.65.0 and 0.65.1 never reached npm (8898883)

0.65.0 was not tagged (merge CI failed on a racing test, fixed in #651). 0.65.1 was tagged, but its Publish run failed at "Build the release notes": the changelog heading below it is 0.65.0, and v0.65.0 does not exist. Tags cannot move, so the fix goes forward as 0.65.2. Its previous heading is 0.65.1, whose tag exists, so the notes step can run.

Validation: git diff origin/main shows only CHANGELOG.md and README.md.


Full diff: v0.65.1...v0.65.2

v0.64.0: Every Request, Kept

Choose a tag to compare

@github-actions github-actions released this 31 Oct 04:49
de9c022

Added

  • Every model request is stored whole, by default (#586). Each authenticated model request, on every API, leaves one record under its app: the request as received (method, path, query, every header, the body byte for byte), the response as sent (status, headers, the full body, a stream's raw SSE bytes), the timing, the routing (model requested and served, the fallback chain, breaker decisions, cache result), the usage and cost, the model's full answer assembled from the stream, and every error with its full body, the host's own error included. An app's key reads its own record with GET /plugboard/requests/{request id or session id}; another app's id is a 404. Only the values of Authorization, x-api-key, x-goog-api-key and a key query parameter are redacted. The option is history.storeRequestBody (default true, per app under apps.<name>.history); false stores nothing. Nothing is capped or pruned, and images and streams can be megabytes per request. The option name history was a retired spelling of sessions; its old fields now say they moved there.
  • The request log line shows how big the request was (#586). Each model request logs body_bytes, and the count and bytes of its system prompts, messages and tools (system_count, system_bytes, message_count, message_bytes, tool_count, tool_bytes). A small question that costs thousands of cached prompt tokens now shows where they come from, usually the tool list.

The full story

13 commits since v0.63.1, grouped by type and oldest first. Each entry is the commit message as written.

Features (2)

feat(http): log the size of each model request (3806ab4)

A request session showed about 14.5k cached prompt tokens for a 616-byte question, and nothing in plugboard said how big the request or its tool list was. The request log line now carries body_bytes, system_count, system_bytes, message_count, message_bytes, tool_count and tool_bytes.

What changed

  • src/request-size.ts: the raw body is kept per request (readJsonBody remembers it), and the sizes are the UTF-8 size of the JSON of system, messages and tools as decoded, before the session remaps the tools. An empty part is 0 bytes, not the 2 bytes of "[]". A completions body with several prompts adds them up.
  • Every model route (chat, completions, responses, messages, gemini) notes the body after reading it and the sizes once the request is decoded. The access line spreads them in. A request refused before it is decoded has none.
  • Docs: configuration.md (Operating) and the monitoring guide.

Decisions

  • The existing request line, not a new event: it already carries the app, model and id, so a size can be matched to a request. Counted per HTTP request, once, whatever the fan-out or the fallback chain does.
  • Sizes come from the decoded PlugRequest, not each wire shape's own tool array, so the five front-ends share one definition. The wire wrappers (type: function, functionDeclarations) are not counted.
  • request_tool_count and request_tool_bytes are not added to the usage record: the day record, its JSON reader, the sums and the report would all change, which is not a one-line change. Log only.

Validation

  • bun run validate --mutations --skip examples,test: lint, typecheck, manifest, pack, docs and mutations pass (ten new P586 entries).
  • bun test: 3886 pass, 0 fail. The validate test step still exits 1 on main as well: coverage 98.3% against a 98.9% floor, with the live suite skipped on this machine. Floors for test counts are raised.
  • Live, isolated server, github-copilot/claude-haiku-5.5, a 336-byte chat body with one system message and one tool: the line printed body_bytes=336 system_bytes=18 message_bytes=63 tool_count=1 tool_bytes=141; wc -c gave 336 for the body.

Refs #586

feat(http): store every model request whole, by default (d053419)

Plugboard kept no copy of what a client sent or what it got back, so a request that cost far more than expected, or answered wrongly, could not be looked at afterwards. Each authenticated model request now leaves one JSON record under its app, on by default, with no size cap.

The record holds

  • request: method, path, query, every header, the body byte for byte ({ text }, or { base64 } when the bytes are not UTF-8);
  • response: status, every header, the full body as sent, a stream as its raw SSE bytes;
  • timing: received, first byte, finished;
  • routing: app, model requested, model served, the fallback attempts with their error bodies, the fallbacks taken, breaker decisions, cache result;
  • calls: per model call (one per choice of n) the answer assembled from the events (text, reasoning, tool calls, finish reason), the usage as the model reported it, the cost, the session id;
  • errors: the error the client got, the plugboard error of each call, and the host's own error before it is mapped to a caller-safe one.

Where it lives and who reads it

  • The host's plugin storage (ctx.storage, the same table as usage and the history index): record//, plus record-session// so a session id finds it. Session metadata and prompt metadata were rejected: session lists carry every row's metadata and its events go to every connected client, so megabytes there slow OpenCode itself.
  • GET /plugboard/requests/{request id or session id} with the app's key. The key is built from the authenticated app, never the input, so another app's id is the same 404 as an unknown one. The admin key reads no record (I34).

How

  • src/record.ts: ExchangeRecorder, the response tee (write, end, writeHead copy what is sent; nothing about the response changes), the recording model-caller layer (events and the hooks onRecord, onServed, onCache, onPolicy, onAffinity, onUsage), and an async-local store so fallback.ts and the session caller report to the request's recorder with no signature change. The session caller binds the recorder when it builds its collector, because host events arrive from the event pump.
  • onUsage is always set while recording, so a stream gets its cost too.
  • src/openai/route.ts creates the recorder after authentication and writes it when the response is over.

Option

  • history.storeRequestBody (default true), per app in apps..history. One option covers everything. false stores nothing. maxRecordBytes was not needed and is not added.
  • history was a retired spelling of sessions. Its old fields (maxSessions, maxAgeDays, hideFromList, hidden) now say they moved to sessions.. Tests of the old rename hint are updated.

Redaction: the values of Authorization, x-api-key and x-goog-api-key, as asked, and also a key query parameter. Gemini clients send their key there, and storing it would put a live credential in the record.

Not done: records are not pruned with their sessions (the owner will clean up); no list or delete route.

Validation

  • bun run validate --mutations --skip examples,test: lint, typecheck, manifest, pack, docs, mutations pass (27 new R586 entries).
  • bun test: 3914 pass, 0 fail. The validate test step still exits 1 on main as well, coverage 98.3% against a 98.9% floor with the live suite skipped here; test-count floors are raised.
  • Live, isolated server (github-copilot/claude-haiku-5.5): a streamed, a non-streamed and a refused (unknown model) request each stored a record readable by their id; the stream record held the raw SSE bytes and the assembled "Pong"; cost 3.6e-6 USD was recorded for the stream; Authorization was [redacted]; the other app's key got 404 record_not_found and a wrong key 401.

Refs #586

Fixes (1)

fix(ci): ignore the skipped Auto-merge check in the release gate (5a39c1b)

bun run release stopped on the 0.64.0 release pull request with "1 check(s) did not pass ... skipping: Auto-merge". The gate counts a skipped check as failed (a job that did not run proves nothing), and the Dependabot job in dependabot-auto-merge.yml, added on 2026-10-24, is skipped on every pull request that is not Dependabot's. So every release pull request would fail the gate, and --resume fails the same way.

What changed

  • .github/scripts/merge-gate.ts: ADVISORY_CHECKS now holds the benchmark and Auto-merge, and a pure judgeChecks() splits the checks into gating, failing (failed, cancelled, skipped) and pending. The set moved here from release.ts so a test can feed it.
  • .github/scripts/release.ts: waitForChecks uses judgeChecks.
  • docs/releasing.md names the rule and where to change it.
  • test/unit/merge-gate.test.ts: a skipped non-advisory check still fails; a skipped (or failed, or pending) advisory check is ignored; a release pull request with passing checks and the skipped Auto-merge job passes; a pending non-advisory check keeps the gate waiting.

Rejected: treating every skipped check as a pass. The skipped-counts-as- failed rule protects against a required job silently not running.

Validation

  • bun test test/unit/merge-gate.test.ts: 11 pass.
  • bun run validate --only lint,docs,manifest,pack: pass. typecheck pass.
  • bun run release 0.64.0 --dry-run runs against the new modules.
  • The validate test step still exits 1 on main as well (coverage floor with the live suite skipped locally), 0 failing tests; floors raised.
  • The mutation harness copies src and test only, so no entry covers .github/scripts.

Fixes #591

Docs (1)

docs(session): instruction files do not reach a request's model ([2c272fd](https://github.com/jjjerm...

Read more

v0.63.1: Signed Thinking Again

Choose a tag to compare

@github-actions github-actions released this 24 Oct 15:39
ac909c4

Fixed

  • A thinking block keeps its signature on OpenCode 2.1. The host renamed the reasoning event's payload field from state to native (packages/core/src/session/runner/publish-llm-event.ts publishes native; packages/schema/session-legacy.ts renames it on the durable events). Plugboard read state only, so on 2.1 every thinking block reached the caller with signature: "" while the reasoning text still arrived, and a client that sent that block back got it refused. The reasoning end now reads either field.

    Found by the check matrix on a hub running 2.1.4: api: thinking/count_tokens went from thinking block signed on 2.0.32 to thinking block missing on 2.1.4, reproducible on three runs.

The full story

1 commit since v0.63.0, grouped by type and oldest first. Each entry is the commit message as written.

Fixes (1)

fix(session): read the reasoning signature from native on OpenCode 2.1 (e723e8e)

OpenCode 2.1 renamed the reasoning event's payload field from state to native, so on 2.1.4 every thinking block reached the caller with an empty signature while its text still arrived.

Host evidence, both at the tags in anomalyco/opencode:

  • packages/core/src/session/runner/publish-llm-event.ts publishes native: native(event.providerMetadata) on session.reasoning.ended (v2.1.4); v2.0.32 published the same value as state.
  • packages/schema/session-legacy.ts carries the rename for durable events ("session.reasoning.ended": ["state", "native"]), and SessionMessage.Native is documented there as provider-owned replay data keyed by the producing provider's metadata fields, so the value is the same shape signatureOf already reads.

Found on the hub after the 2.1.4 upgrade: the check matrix row api: thinking/count_tokens reported thinking block signed on 2.0.32 and thinking block missing on 2.1.4, three runs in a row. The body of the /v1/messages answer carried the thinking text with "signature": "", so the loss was in the plugin, not the provider.

signatureOf handled both the unwrapped and the provider-wrapped form, but the caller only ever passed item.state. The reasoning end now passes item.state ?? item.native, which keeps 2.0.x working (no native there) and restores the signature on 2.1.

Two tests in test/session/collector.test.ts cover the new field (a signature and a redacted block). Both fail with state only; with the fix the collector suite is 99 pass and the full suite 3875 pass, 130 skip, 0 fail. The test helper takes the field name as an argument so the old field stays a case of its own.

A client that replays an unsigned thinking block is refused, so this would have broken every Anthropic thinking conversation at its second turn on 2.1.


Full diff: v0.63.0...v0.63.1

v0.63.0: Runs on OpenCode 2.1

Choose a tag to compare

@github-actions github-actions released this 24 Oct 15:03
c9358bb

Changed

  • OpenCode 2.1 is supported. The @opencode/plugin peer range moves from >=2.0.26 <2.1 to >=2.0.26 <2.2. Until now a 2.1.x server refused to load plugboard, which also kept an autoupdating server on 2.0.32: the host checks its peer range before it moves. Nothing in the plugin API moved. dist/promise/index.d.ts, dist/effect/index.d.ts and dist/tui/index.d.ts are byte-identical between @opencode/plugin 2.0.32 and 2.1.4, so Plugin.define, the plugin context and the TUI context are the same surface. 2.1 adds ctx.session.list and an optional native field on the prompt input, and neither is used here. The TUI half keeps its @opentui floor at 0.5.17: the host hands its own OpenTUI to a plugin at runtime, and 0.5.17 is what a 2.0.x host serves.

    Checked live on an isolated OpenCode 2.1.4 server. The plugin reports state: active with the server, tui and rpc features, /v1/models lists 478 models, a chat request creates its session and reaches the provider (the isolated home has no valid sign-in, so the provider refused it, which is upstream of plugboard), and the /plugboard page renders its tables and answers w with the 7 day range.

  • The devDependencies move to the new host: @opencode/plugin 2.1.4 with effect 4.0.1. npm-shrinkwrap.json is regenerated as check-pack requires, which also takes @opentelemetry/api from 1.9.1 to 1.10.0, a transitive of pino. No direct runtime dependency changed.

The full story

2 commits since v0.62.1, grouped by type and oldest first. Each entry is the commit message as written.

Docs (1)

docs: state the 2.1 support, the new pin and the peer range (b1b599d)

The supported-version line, the dev pin and the peer range in docs/architecture.md, docs/development.md, docs/packaging.md and AGENTS.md named 2.0.26 to 2.0.32 and >=2.0.26 <2.1. They now name the range the code declares.

architecture.md also said ctx.session has no list. That was true of 2.0.x and is not true of 2.1, so the sentence says which host gained it and why the index stays: the floor is still 2.0.26, and a host without list cannot answer the question the index answers.

packaging.md's host check now names @opencode/plugin 2.1.4 and the modules read at that version (dist/host.js, dist/source.bun.js, dist/source.node.js, dist/runtime*.js), all byte-identical to 2.0.32, which is the evidence the peer claim rests on.

Validation: bun run docs:build, bun run lint and bun run format pass.

Dependencies (1)

build(deps): support opencode 2.1.4 (e911ea0)

OpenCode 2.1 shipped. Bumping the host pin moves the peer range's upper bound to <2.2, so an install on 2.1.x stops being refused, and the typecheck and the tests run against the new surface.

What changed in the host's plugin API, read from the two published packages rather than the release notes (npm pack @opencode/plugin@2.0.32 and @2.1.4, then a file-by-file diff of dist/):

  • The entry points are byte-identical: dist/promise/index.d.ts, dist/effect/index.d.ts and dist/tui/index.d.ts do not differ, so Plugin.define, the plugin context (ctx.options, ctx.event, ctx.agent, ctx.tool, ctx.rpc, ctx.storage, ctx.location, ctx.app), the TUI plugin context and every surface plugboard uses are unchanged.
  • SessionDomain gains list (dist/promise/session.d.ts), and the adapter serves session.list. Additive: nothing plugboard calls moved or disappeared.
  • The prompt input gains an optional native field, and Permission.AssertInput.sessionID's brand loses a descending helper (internal to the host's ID factory). Neither is reachable from plugboard: it never sends native, and it passes a session ID it already holds.
  • DeepMutable moved from a local promise/types.ts to effect's Types.DeepMutable. Same type, different home; no call site changes.
  • The published peerDependencies of @opencode/plugin@2.1.4 raise @opentui/core and @opentui/solid to >=0.6.8. The TUI half of plugboard still declares and pins 0.5.17 on purpose: the host hands its own OpenTUI to a plugin at runtime (build-tui.ts keeps literal @opentui/* specifiers, the host rewrites them), TextAttributes is unchanged between 0.5.17 and 0.6.8, and 0.5.17 is the floor that keeps a 2.0.x host working. Typechecking against the floor is the stricter of the two.

effect stays 4.0.1; @opencode/plugin@2.1.4 pins the same version.

npm-shrinkwrap.json is regenerated with the recipe in docs/packaging.md because check-pack.ts compares its root peerDependencies with package.json. It had not been regenerated since 0.23.0, so the same run also takes @opentelemetry/api 1.9.1 -> 1.10.0 (a transitive of pino/@prometheus-io/client) and updates the two version fields. No direct runtime dependency changed.

Validation: bun run typecheck (including HostContextMatches and the TUI tsconfig), bun test (3873 pass, 130 skip, 0 fail, 4003 tests), bun run lint, bun run format, bun run build:tui, check-pack.ts, smoke-pack.ts and since-next.ts --check all pass on the 2.1.4 pin. host-versions.ts now picks 2.0.26, 2.0.30 and 2.1.4 for the CI host matrix without an edit to the workflow.


Full diff: v0.62.1...v0.63.0

v0.62.1: Clearer Waiting Messages

Choose a tag to compare

@github-actions github-actions released this 21 Oct 06:29
bf5176b

Changed

  • Two first-run messages say more. GET /readyz and GET /plugboard/ready now explain the 503 you get after an OpenCode restart: "waiting for OpenCode to load plugboard", that the first model request to an app or a warm-up call makes it ready, and a link to the warm-up page. Readiness itself is unchanged: /readyz stays 503 until an app directory has served a request, because answering 200 earlier would send traffic to a server whose first request still has to boot the directory. An unknown model on a Gemini generateContent, streamGenerateContent or countTokens call now points at GET /v1beta/models, not GET /v1/models.

The full story

1 commit since v0.62.0, grouped by type and oldest first. Each entry is the commit message as written.

Fixes (1)

fix(http): say why /readyz is 503 after a restart, and point Gemini at /v1beta/models (0c21cdf)

Two bits of first-run friction left over from the last walkthrough.

/readyz: after an OpenCode restart it answered 503 "no app directory has been used since start; one model request to an app confirms it". The reason now starts with "waiting for OpenCode to load plugboard", says the first model request to an app or a warm-up call makes it ready, and links the warm-up page. The old phrase is still inside the text.

Gemini: an unknown model on generateContent, streamGenerateContent and countTokens said "GET /v1/models lists the models you can call", a route a Gemini client does not use. encodeGeminiError now rewrites that hint to GET /v1beta/models for model_not_found, so every path that reaches a Gemini error body (route, session, fallback) gets it. The GET /v1beta/models/{model} route already did.

Decision: readiness rule unchanged. servingConfirmed is the only signal that an app directory has booted in this process; answering 200 on listener-up alone would send load-balancer traffic to a server whose first request still boots the directory (a few seconds, and the pump and catalog load there too). Wording only.

Rejected: threading a list-route parameter through resolveModel, checkAppModel and the session caller. The encoder is the one place every Gemini error passes through.

Docs: errors.md (not_ready), configuration.md (errors table, /readyz line), warm-up.md. CHANGELOG [Unreleased] Changed. Scripted transcripts of the four Gemini examples regenerated; each diff is only the message line (and its fieldViolations copy). Tests: newcomer-errors (generate, stream and count on a Gemini route), readyz (exact reason).

Validation: bun run validate --skip examples passes. The full examples step passed through every client up to langchain4j, which needs a Java runtime this machine lacks (unchanged OpenAI-shape transcripts).


Full diff: v0.62.0...v0.62.1

v0.62.0: Friendlier First Steps

Choose a tag to compare

@github-actions github-actions released this 21 Oct 05:45
778c74e

Changed

  • First-run messages say what to do next. A wrong or missing key says to use a key from keys, not a provider key. An unknown model says that GET /v1/models (Gemini: GET /v1beta/models) lists the models you can call, and the 403 for an empty models shows "models": "all". A GET on a POST route says which method it takes. A provider that rejected its sign-in says to run /connect on the OpenCode server and that your plugboard key is fine. An option name from before the renames (history, maxN, cache, apps.<name>.models and the rest) now fails at load with it is renamed to <new name>, models: "connected" says it is renamed to "all", and a typo says did you mean <name>? instead of listing every option. The status codes, error codes and each API's error shape are unchanged.
  • init prints numbered next steps. Restart OpenCode and touch the server (again after every restart), list the models, call one, and a link to the table of what to check when nothing answers. The key line no longer says "shown once" next to "it is also in the file".
  • The setup page reads cleanly and tells a browser user what to do. Backtick spans in its hints and endpoint list show as code instead of literal backticks. Without a key, the page says a browser cannot send one and gives the curl that shows your models.
  • The install docs match what init does. Every init command is bunx opencode-plugboard@latest init, so Bun cannot reuse an older copy and pin an old release. The first request lists the models first and sets PLUGBOARD_KEY_MY_APP from the printed key (the page called fast, which only exists with --fast). The "If it does not answer" table has the provider_auth_failed and invalid-options cases.

Added

  • Split one app's usage by a request tag. Send x-plugboard-tag: cron on any model request (Chat Completions, completions, Responses, Messages or Gemini) and the usage report counts requests, errors, tokens and cost per tag, next to the per-user split. GET /plugboard/usage has a tags object per app and a ?tag= filter, and the dashboard Usage tab shows a Tags table. A tag is 1 to 64 characters of A-Z a-z 0-9 . _ : -; a bad one is ignored with a debug log line, never an error. A day keeps 100 tags per app and the rest count under _other. Prometheus metrics get no tag label, because a free-form value has unbounded cardinality. The CORS preflight allows the new header.
  • Ready-made monitoring for the Prometheus metrics. monitoring/prometheus-rules.yml has recording rules and alerts for a high 5xx share, 429 refusals from limits and budgets, an open circuit breaker, fallback spikes, high p95 latency, a dead event pump and a missing or down scrape target. monitoring/grafana-dashboard.json imports into Grafana 11 with a datasource and an app variable: requests, errors, latency, tokens, cache hit ratio, breaker state, fallbacks and refusals. CI checks the rules with a pinned promtool, and a test keeps every metric and label they use in step with the exposition. Setup is in the monitoring guide. Cost and per-model figures are not metrics, so they stay in the usage report.
  • Control the response cache per request. Send x-plugboard-cache: bypass to skip the cache for one call (no lookup, nothing stored), or refresh to skip the lookup and replace the stored answer. x-plugboard-cache-namespace: <name> keeps a caller's entries apart inside one app, and never reaches another app's entries. Cache-Control: no-store and no-cache mean bypass and refresh, and Portkey's namespace and force-refresh headers and Helicone's cache seed work too. The answer reports bypass or refresh in x-plugboard-cache. The headers only act for an app that uses responseCache; a bad value is a 400 invalid_cache_header in each API's own error shape. Browsers may send them: the CORS preflight now allows them.
  • Budgets: two windows on one metric are tested and documented. "requests": { "day": 1000, "month": 20000 } already enforced both, and the 429 names the window that tripped. The behaviour now has tests and a docs example.

The full story

7 commits since v0.61.0, grouped by type and oldest first. Each entry is the commit message as written.

Features (3)

feat(cache): per-request response-cache headers, and tests for multi-window budgets (78d2db4)

Response cache: a caller now steers the cache for one request with headers, without a config edit.

  • x-plugboard-cache: bypass skips the lookup and the store; refresh skips the lookup and stores the new answer.
  • x-plugboard-cache-namespace (1 to 64 of letters, digits and _ . : / + = @ -) is folded into the cache key right after the app, so a namespace never reads another app's entries.
  • Cache-Control: no-store is bypass, no-cache is refresh, other directives are ignored; x-plugboard-cache wins when both are sent.
  • Conventions of other gateways, checked against their docs: Portkey's x-portkey-cache-namespace and x-portkey-cache-force-refresh: true, and Helicone's helicone-cache-seed (a namespace). Cheap to read, so a client written for them works unchanged.
  • The response header x-plugboard-cache reports bypass or refresh when the directive took effect, else hit or miss. A refresh counts as a miss in plugboard_cache_requests_total; a bypass is not counted.
  • The directive only acts where the cache already applies (app in responseCache.apps, not a persona, not n above 1).
  • A bad value (unknown x-plugboard-cache, empty or unsafe namespace) is a 400 invalid_cache_header in each API's own error shape, checked before the body is read and whether or not the app uses the cache. Rejected: ignoring bad values, because a typo like "bypas" would then silently serve cached answers.
  • CORS preflight allow-headers lists the new request headers.

Wiring: CallContext gets cache (the directive) and onCacheDirective, beside onCache, so withUsage (src/usage.ts) keeps its hit or miss typing and is not touched. Chat, Responses, Completions, Messages and Gemini parse the headers and pass the directive; the fan-out passes it for a single choice.

Budgets: two windows on one metric already worked (ceilingsOf enforces every window and the 429 names each exceeded one). Added unit and loopback tests and a docs example, no source change.

Tests: unit tests for the parser and the wrapper, loopback tests for each mode on Chat, Responses, Messages and Gemini (plus streaming and completions), namespace isolation, cross-app isolation, apps outside the cache, bad values in each error shape, and the preflight. The CORS allow-headers assertion and its mutation anchor change with the new string, and the cacheKey mutation anchor follows biome's multi-line layout. 18 mutation entries added.

Validation: lint, typecheck, test, manifest, pack and docs pass; the mutation run kills every entry (two anchors were re-run alone after a layout change).

feat(metrics): ship prometheus alert rules and a grafana dashboard (5602805)

Adds monitoring/prometheus-rules.yml (5 recording rules, 9 alerts) and monitoring/grafana-dashboard.json (Grafana 11, datasource and app variables, 16 panels), a Monitoring section in the history-usage-and-monitoring guide, a CI step, and a unit test.

The alerts are PlugboardDown, PlugboardTargetMissing, PlugboardHighErrorRate (5xx share over 5m, with a minimum-traffic guard), PlugboardRateLimited (rate and concurrency 429s), PlugboardBudgetExhausted, PlugboardCircuitBreakerOpen, PlugboardFallbacksSpiking, PlugboardHighLatency (p95) and PlugboardEventPumpDown. Every threshold has a tuning comment.

Decisions:

  • No budget-used gauge exists (only the budget.warning log line and the budget 429 counter), so the budget alert fires on refusals. Adding a metric was left out: it touches src/ and the usage store.
  • The breaker gauge counts only open targets and reads 0 in half-open, so a target that keeps failing flaps. The alert averages the gauge over 10m instead of requiring it non-zero at every sample.
  • No cost or model series exist (model names are caller text), so the dashboard shows tokens by app and kind and points to the usage report.

Validation:

  • promtool 3.15.0 check rules: 14 rules found.
  • Ran a real Prometheus 3.15.0 against a target built on createMetrics(): all 14 rules healthy, the traffic-driven alerts fired, and all 18 dashboard queries returned series.
  • Scraped the isolated dev server with metrics on and checked the queried names and labels against the real exposition.
  • test/unit/monitoring.test.ts checks every metric and label the rules and dashboard use against the registry, and the dashboard shape.
  • bun run validate --skip examples: 6 steps passed.

Not verified: Grafana import was not run (no Grafana available); the dev server had no provider credentials, so tokens, cache, fallback and breaker series came from the registry-backed target, not from model calls.

feat(http): split one app's usage by a request tag (411da67)

One app often does different jobs: a bot's cron runs and its interactive chat. Usage split by app and by end user only, so telling those apart needed a second key. A request can now carry x-plugboard-tag: <tag> and the usage report counts requests, errors, tokens and cost per tag.

What changed:

  • src/tag.ts reads the tag: the x-plugboard-tag header on every model route (Chat Completions, completions, Responses, Messages, Gemini), else metadata.tag on the OpenAI shapes. One tag per request, 1 to 64 characters of A-Z a-z 0-9 . _ : -, trimmed. The header wins; a refused header does not fall back to the body. A bad or reserved value (...
Read more

v0.61.0: Names All the Way

Choose a tag to compare

@github-actions github-actions released this 20 Oct 19:58
831f61b

Changed

  • Breaking: log events, metrics, a header and error codes follow the new option names. The options were renamed in the last release, but the names you see at runtime still used the old words. They now match: the circuit breaker, OpenCode tools and sessions. There are no aliases, so update dashboards, alerts, log filters and error handling that use the old names:

    Old New
    plugboard_fallback_cooldown_events_total, plugboard_fallback_cooldown_targets plugboard_circuit_breaker_events_total, plugboard_circuit_breaker_targets
    plugboard_history_sweeps_total, plugboard_history_sessions_removed_total plugboard_sessions_sweeps_total, plugboard_sessions_removed_total
    log events fallback.cooldown.open, .skip, .close, .all_open circuit_breaker.open, .skip, .close, .all_open
    log events history.swept, .sweep_failed, .sweep_timeout, .index_failed, .index_skipped sessions.swept, .sweep_failed, .sweep_timeout, .index_failed, .index_skipped
    log events server_tool.call, server_tool.after_request opencode_tool.call, opencode_tool.after_request
    header x-plugboard-server-tools x-plugboard-opencode-tools
    error codes server_tool_collision, server_tool_denied_by_operator, server_tool_unavailable, server_tool_steps_exceeded, server_tool_calls_exceeded opencode_tool_collision, opencode_tool_denied_by_operator, opencode_tool_unavailable, opencode_tool_steps_exceeded, opencode_tool_calls_exceeded
    error code app_has_server_tools (admin keys) app_has_opencode_tools

    The x-plugboard-opencode-tools header is still in the CORS expose list. Internal names changed to match (for example sweepSessions, sessionUrlTemplate, reportOpencodeTools).

Docs

  • A plainer home page. The docs home opens with a short serif headline, one copyable install command (bunx opencode-plugboard init) and a terminal window that shows the first run and a curl call, instead of buttons and feature cards. The terminal is short (one curl line and a three line answer), so the sidebar and the first heading are visible on the first screen at 1440 by 900. The top bar is just the name, search, the latest release, the theme switch and GitHub. The pages that used to be in the top bar (guides, clients, reference, community) are in the sidebar. The headline font is installed from npm, so the site makes no font request to a third party.

The full story

2 commits since v0.60.0, grouped by type and oldest first. Each entry is the commit message as written.

Refactors (1)

refactor(config): rename runtime names to match the renamed options (2ec89f7)

The options were renamed in the last release (fallbackCooldown -> circuitBreaker, serverTools -> opencodeTools, history -> sessions), but the names an operator sees at runtime still used the old words. They now match. Breaking, accepted pre-1.0, no aliases.

What:

  • Metrics: plugboard_fallback_cooldown_* -> plugboard_circuit_breaker_*; plugboard_history_sweeps_total / _sessions_removed_total -> plugboard_sessions_sweeps_total / plugboard_sessions_removed_total.
  • Log events: fallback.cooldown.* -> circuit_breaker.; history.swept, .sweep_failed, .sweep_timeout, .index_failed, .index_skipped -> sessions.; server_tool.call / .after_request -> opencode_tool.*.
  • Header x-plugboard-server-tools -> x-plugboard-opencode-tools, still in the CORS expose list.
  • Error codes server_tool_* -> opencode_tool_*, app_has_server_tools -> app_has_opencode_tools.
  • Internal names follow: sweepSessions, SessionLimits, appSessionLimits, sessionUrlTemplate, onHostedToolsFor, reportOpencodeTools, onOpencodeTools, ungrantedOpencodeTool, the events collector's opencodeCalls, the metrics sink's breaker(). Test files fallback-cooldown.test.ts and server-tools.test.ts are renamed, and the mutation catalogue anchors and ids with them.
  • Docs home: the hero terminal is shorter (no --fast, one curl line, a three line answer, tighter spacing) so the sidebar and the first heading show on the first screen at 1440x900 (hero 890 px -> 638 px).

Decisions: the plugboard_history_* sweep metrics were renamed too, beyond the two named in the brief, because the option is now sessions and the sweep log events moved with it. Not renamed: the on-disk history/ index key prefix (stored data), the runtime history port, and Anthropic's own server_tool_use wire field.

Validation: bun run validate --mutations --skip examples (7 of 7 passed), bun run docs:build.

Docs (1)

docs: redesign the site home page as a plain doc page with a terminal hero (45b7064)

The home page looked like a generic startup hero: a mono eyebrow, a bold sans headline, three pill buttons, a boxed diagram and six feature cards, under a nav with five menus. It now follows the plain look of kimaki.dev.

What changed

  • docs/index.md is a normal doc page (layout doc) with homeHero: true. A new HomeHero.vue is rendered in the layout-top slot, so it sits above the docs layout and the sidebar and the text start on the home page itself. No feature cards.
  • HomeHero.vue: a serif italic headline in two lines (Instrument Serif, installed from npm with @fontsource/instrument-serif and preloaded through transformHead, so no font CDN request), one sentence, one install command in a copyable box (useClipboard from @vueuse/core, the version VitePress already depends on), and small GitHub and npm links with inline simple-icons marks.
  • HomeTerminal.vue: the product picture is a framed terminal showing bunx opencode-plugboard init and a curl to /v1/chat/completions with its answer. The text is real output; the key is cut and the answer keeps four fields.
  • The sidebar starts under the hero and then sticks under the top bar. It is moved with a transform set on the sidebar element only, so scrolling causes no layout shift. The hero is ordered before the content so that on a phone it comes after the top bar.
  • The top bar is the wordmark (site name in the serif), search, the latest release, the theme switch and GitHub. Get started, Guides, Clients and Reference were already in the sidebar; Community (discussions, issues, contributing, changelog) is a new sidebar group.
  • A dot field behind the hero only, fading out toward the sides and the bottom.

Rejected

  • VitePress layout home: it has no sidebar. Overriding VPContent padding on a home layout would fight the theme more than the layout-top slot does.
  • A real screenshot of the dashboard: heavy, goes stale, and does not show the first command a reader runs. HTML text also stays crisp in light and dark and on a phone.
  • The npm mark through the vpi-social-* classes: VitePress only emits CSS for icons that its own components render, so the npm class had no rule.

Checks

  • bun run docs:build, bun run validate (lint, typecheck, test, manifest, pack, docs pass; the examples step needs a Java runtime for langchain4j, which this machine lacks).
  • Headless Chrome at 1440 and 390 px, light and dark; clipboard read back after a click; the network log shows only the local host; layout shift 0.0136 at load (the same value as on any other docs page, from the nav hydrating) and 0 while scrolling.

Full diff: v0.60.0...v0.61.0

v0.60.0: Names That Make Sense

Choose a tag to compare

@github-actions github-actions released this 20 Oct 06:31
b797b64

Changed

  • Breaking: renamed options. Seventeen option names were confusing, so they now say what they do. There are no aliases: an old name is refused at load as an unknown option, with the usual error that lists the known ones. Rename these in your config:

    Old New
    models: "connected" (global, and apps.<name>.models: "connected") models: "all"
    apps.<name>.models apps.<name>.allowedModels
    apps.<name>.model apps.<name>.pinnedModel
    cache responseCache
    apps.<name>.serverTools apps.<name>.opencodeTools
    apps.<name>.maxServerToolSteps apps.<name>.maxToolSteps
    apps.<name>.maxServerToolCalls apps.<name>.maxToolCalls
    apps.<name>.hostedTools apps.<name>.onHostedTools (values drop and refuse)
    history (global and per app) sessions
    history.hidden sessions.hideFromList
    historyUrl (global and per app) sessionUrl
    maxN (global and per app) maxChoices
    fallbackAfterMs firstEventTimeoutMs
    fallbackCooldown circuitBreaker
    fallbackCooldown.failureThreshold circuitBreaker.failures
    fallbackCooldown.windowMs circuitBreaker.failureWindowMs

    fallbackCooldown.cooldownMs keeps its name inside circuitBreaker, and the global models list keeps its name. The GET /plugboard/status field fallbackCooldown is now circuitBreaker, and the warning log line history.hidden_parent_unavailable is now sessions.hide_from_list_parent_unavailable. plugboard init writes "models": "all".

The full story

1 commit since v0.59.0, grouped by type and oldest first. Each entry is the commit message as written.

Other (1)

refactor(config)!: rename the most confusing options (a946eca)

Seventeen option names said little about what they do (history is the pruning limits of request sessions, serverTools are tools OpenCode runs, cache is the response cache, models: "connected" allows every model). The owner picked new names and chose no aliases and no deprecation code, since nobody else uses the options yet. An old name now fails at load as an unknown option, with the existing error that lists the known ones.

Renamed: models: "connected" -> "all" (global and apps.<name>.allowedModels), apps.<name>.models -> allowedModels, apps.<name>.model -> pinnedModel, cache -> responseCache, serverTools -> opencodeTools, maxServerToolSteps -> maxToolSteps, maxServerToolCalls -> maxToolCalls, hostedTools -> onHostedTools, history -> sessions (hidden -> hideFromList), historyUrl -> sessionUrl, maxN -> maxChoices, fallbackAfterMs -> firstEventTimeoutMs, fallbackCooldown -> circuitBreaker (failureThreshold -> failures, windowMs -> failureWindowMs). The global models list keeps its name.

Internal identifiers follow (AppPolicy and PlugboardOptions fields, maxChoicesFor, ALL_MODELS, CircuitBreaker). The GET /plugboard/status field fallbackCooldown is now circuitBreaker, and the warn log line history.hidden_parent_unavailable is now sessions.hide_from_list_parent_unavailable. Left alone on purpose: the breaker log events fallback.cooldown.*, the x-plugboard-* headers, the error codes and the runtime history port, which are not options.

Updated every usage: src, tests, the mutation catalogue anchors, the docs site, README, STRUCTURE, SECURITY and the docs full-config helper, which now adds "models": "all". The CHANGELOG has a breaking "Renamed options" table under Unreleased.

Validation: typecheck, lint, format, bun test and bun run docs:build pass; see the PR for the full validate run and the live check.


Full diff: v0.59.0...v0.60.0

v0.59.0: Locked Down, Clearly Documented

Choose a tag to compare

@github-actions github-actions released this 20 Oct 04:52
1e716e9

Added

  • The Aider example now checks a file edit through plugboard (#215). A new shared scenario, S11, asks Aider to change a file in a throwaway git repo. The scripted model answers in the edit format Aider picks for the model, whole file for fast and openai/gpt-5.6-luna#low, SEARCH/REPLACE blocks for anthropic/claude-haiku-4-5, and the runner checks that the file changed. The committed scripted run has the three transcripts, and the Aider page says which edit formats work.
  • The AutoGen example now runs on the Anthropic Messages route as well (#230). It is a uv run project with exact pins and a lockfile, and its runner is part of the scripted CI check. Chat and the two-agent tool round trip run on Chat Completions (api_type: openai) and on Messages (api_type: anthropic, base_url without /v1), and the stream runs on Chat Completions. The AutoGen page shows both configs.
  • The Claude Code example runs (#102). examples/claude-code now has a runner that starts the pinned @anthropic-ai/claude-code (2.1.303) headless with claude -p, a fresh HOME and CLAUDE_CONFIG_DIR, an empty workspace and no login, telemetry or update traffic. Three new scenarios run against the scripted model through /v1/messages: S12 (the model asks for Write, then Read, then answers, and the file exists), S13 (the Agent tool: Claude Code sends the sub-agent's own request and the answer returns as a tool result) and S14 (/compact on a resumed session, then a turn that sees only the summary). A chat and a stream run on three models too. The transcript writer now also fixes the platform, shell, paths and sub-agent id that Claude Code puts in a request, and normalises response bodies, so the committed run is the same on every machine. The Claude Code client page imports the headless call from the example and lists sub-agents and compaction under what works.

Changed

  • The Gemini CLI example runs a token count scenario (#103). The pinned CLI calls :countTokens in a headless run when the prompt refers to an image or a PDF with @name, as its check that the request fits the context window, and the scripted run records that call and its answer. A text-only prompt calls nothing. The scenario is S10 in examples/_shared/scenarios.json. The Gemini CLI client page documents it, and the LangChain Google GenAI page lists the stream cancel limit of @langchain/google-genai (its @google/generative-ai SDK reports an abort as an error).
  • Every page under docs/self-hosted/ links its upstream project, its source and its docs where the project has a docs site (#251).
  • The Gemini CLI example's scripted transcripts no longer change between runs. The CLI lists its built-in skills in the order the filesystem returns them, so the harness now sorts that list by skill name before it writes a transcript.
  • The Langfuse example now traces (#240). Its scripted run points the SDK at a small Langfuse ingestion endpoint on loopback (examples/langfuse/fake_langfuse.py) instead of switching tracing off. A new scenario, S8, checks that the generation of one call arrived with the model name, the answer and the token usage plugboard returned. It needs no Langfuse server and no Docker. docs/clients/langfuse.md now describes the tracing setup and the flush before a short script exits.
  • The PR-Agent example runs (#249). It moved from examples/self-hosted/pr-agent to examples/pr-agent, so the harness can run it. Its runner builds a throwaway git repo with a main branch and a feature branch, then runs the pinned pr-agent (0.48.0, with a lockfile that osv-scanner reports clean) with the local git provider against the scripted model. A new scenario, S9, checks that review.md holds the model's text and that the request reached /v1/chat/completions with the configured model. The scripted model answers PR-Agent's review prompt with a fixed YAML review, and the transcript writer fixes the date PR-Agent puts in that prompt. docs/self-hosted/pr-agent.md shows the local review and the error PR-Agent gives without custom_model_max_tokens.

Fixed

  • The setup page and GET /v1 list the Files and stored-response routes and no longer call Responses stateless (#543). The endpoint list now has GET and DELETE /v1/responses/{id}, GET /v1/responses/{id}/input_items, the OpenAI and Anthropic /v1/files routes and the Gemini /upload/v1beta/files and /v1beta/files routes.

  • On a server that listens on 0.0.0.0 or ::, the setup page and GET /v1 no longer give 0.0.0.0 as the base URL (#544). They use the Host the caller reached the server with, or the public origin a trusted proxy names. When the request names no usable host, they show http://<this-host>:4010 and say to replace it. The Gemini X-Goog-Upload-URL and the file uri follow the same rule, so a client on another machine can send its upload chunks.

  • The apps and keys guide's full config defines the fast alias its discord-bot app pins (#545). A pin that names an alias the config does not define was already refused at load; its error now says the name is not an alias and to add it to aliases.

  • GET /v1/responses/{id}/input_items returns every message in OpenAI's full shape (#546). A message sent as { role, content } now has type: "message", and a string content becomes a list of input_text (or output_text) parts. Responses stored earlier are listed the same way.

  • A file that does not exist is the same 404 on the Files routes and as a file_id in a request (#547): No such File object: <id> in OpenAI's error body on Chat Completions and Responses, File not found: <id> in Anthropic's on Messages, and the Gemini files message on Gemini. The in-request error no longer carries the file_not_found code or a param. The Codex page now says the missing model metadata warning appears for any model id Codex does not know.

  • The fallback deadline fallbackAfterMs starts when the prompt is sent, not when the attempt starts (#548). Fetching remote images and making the session are no longer counted as the model's silence, and a target that was never prompted never counts toward its cooldown. A provider's 4xx answer is now always provider_rejected_request: its wording only picks the rate limit, quota, overload and sign-in classes when the status is absent, 429 or a server status.

  • The docs site's Snippet / Full config tabs no longer make the page jump. Switching every group on the page changed the height of the groups above the one clicked, so the page moved under the reader. The tabs now use vitepress-plugin-tabs (0.9.1, a dev dependency of the docs only), which shares the choice across every group and page by one key and keeps the clicked group still; the hand-written config-tabs.ts script is gone.

Security

  • Dropped the lockfile of the Aider example (#526). It pinned litellm 1.81.10, which carries about 25 advisories that cannot be fixed, because Aider refuses to start with any newer litellm. examples/aider/pyproject.toml still pins aider-chat exactly and caps litellm at 1.81.10, so a fresh resolve starts and the scripted run is unchanged. The runner calls uv run --isolated, which resolves in a temporary environment and writes no lockfile, and Dependabot no longer watches the example. The example ships in no package.
  • Hardened the Files API (#548). The file count and byte checks and the write after them are one step per app, so uploads in flight together stay inside the limits. That queue is bounded and cancellable: at most 8 saves wait behind the running one and the running and waiting saves hold at most 64 MiB, and an upload past either bound is a 429 file_saves_busy with Retry-After at once; a waiting upload leaves the queue, and releases its bytes, when its request ends, and the running save keeps its turn until its write ends. A request that names files is checked against the 50 MB media limit on the file sizes before any file is read (a 400 files_too_large). A resumable upload skips a chunk with no bytes, ends with the key that started it, and expired uploads are dropped by a timer and by any request to the upload route. A text upload's declared Content-Type is read from the file part itself, so text/plain with a name like notes.bin is accepted as the docs say.
  • init writes a new config with mode 0600 and a directory it creates with mode 0700 (#548). A config that already exists keeps its mode.

The full story

30 commits since v0.58.0, grouped by type and oldest first. Each entry is the commit message as written.

Features (1)

feat(examples): run the Gemini CLI's countTokens call through plugboard (555d6f8)

The Gemini CLI example covered a chat and a stream only, and the issue asked for a scenario where the CLI calls :countTokens.

Checked the pinned CLI (0.64.0) in node_modules/@g...

Read more

v0.58.0: Overloaded, Not Stuck

Choose a tag to compare

@github-actions github-actions released this 19 Oct 14:43
0e32e13

Fixed

  • A fallback chain now moves on from a target that answers "overloaded" (#503). Anthropic's 529 (provider_overloaded) was returned to the client as the final error even when the next target was healthy. It now falls back like a rate limit or an outage, and three in a row open the target's circuit breaker. As before, an overload after output has started is never retried.

Changed

  • The code on the client and API pages now comes from the examples that CI runs, not from text written by hand. Every example under examples/ is used by a docs page: SDK and framework pages import their snippets, config-only apps import their config file, and the API pages show the curl examples. A check (examples-index.ts --check, also in bun run validate) fails when an example is in no page of the site or when a <<< import names a missing file or region.

Security

  • The LangChain4j example now builds against Jackson 2.22.3 instead of 2.22.1, which fixes the seven advisories that OpenSSF Scorecard reported in its pom.xml (#523). The shipped package was never affected.
  • Re-checked the 31 open Dependabot alerts in the example lockfiles (#526). No lockfile can move: Aider 0.86.2 is the newest Aider and refuses to start with any litellm after 1.81.10, and every fixed litellm is 1.82.0 or later. The other alerts (diskcache, nltk, two on aider-chat) have no fixed release. All 31 are in example projects that ship in no package, and each is listed in the example's osv-scanner.toml with a reason.

The full story

7 commits since v0.57.0, grouped by type and oldest first. Each entry is the commit message as written.

Fixes (3)

fix(examples): lift langchain4j example to jackson 2.22.3 (d9f9dff)

OpenSSF Scorecard's Vulnerabilities check scored 3/10 for seven advisories (GHSA-7hhh-6rmp-j9qf, GHSA-p6pp-m3f8-5c89, GHSA-cxp5-3px4-pw24, GHSA-gx83-3vf8-gh7j, GHSA-q4xh-88c3-wmh7, GHSA-wjgm-6hv5-3cvf, GHSA-wv8q-qhhj-9h54). All seven are in one place: examples/langchain4j/pom.xml, where LangChain4j 1.22.0 resolves jackson-core and jackson-databind 2.22.1 transitively. Every one is fixed in 2.22.3.

The example now imports jackson-bom 2.22.3 in dependencyManagement, so every Jackson module moves together and LangChain4j itself stays at 1.22.0. The other example lockfiles already carry per-directory osv-scanner.toml ignores that Scorecard honours: with those in place the scan before this change had exactly these seven findings, and none in the root package.

Rejected: upgrading LangChain4j (a Jackson BOM import is smaller and does not change the client the example tests), and excluding the pom from scanning (a real fix exists).

Validation: osv-scanner scan source -r --no-ignore . went from 7 to 0 findings; resolved jackson-core and jackson-databind are 2.22.3. bun run validate passes lint, typecheck, test, manifest, pack and docs. The examples step could not run the Java examples locally (no JDK), so the langchain4j scripted run is left to CI.

Closes #523

fix(examples): record why no Dependabot alert in the example lockfiles can be fixed (c1b8542)

Dependabot lists 31 open alerts, all in example lockfiles. Every alert that has a fixed release is a litellm advisory in examples/aider, and none of those fixes can be taken:

  • aider-chat 0.86.2 is the newest Aider release on PyPI (0.9.0 is older).
  • Aider's LiteLLMExceptions._load raises ValueError when litellm exports an exception class Aider does not list. I installed 1.81.11, 1.81.12, 1.81.13, 1.81.14, 1.81.15, 1.82.0, 1.83.14, 1.88.6+ and 1.106.0 into the locked venv: every one fails with "PermissionDeniedError is in litellm but not in aider's exceptions list". Only 1.81.10 starts, and every fixed litellm is 1.82.0 or later.
  • A uv override therefore cannot help, and patching or shimming Aider to skip its own check would change what the example proves.

The rest have no fixed release (diskcache 5.6.3, nltk 3.10.3, aider-chat 0.86.2, litellm <= 1.82.5 lows); they stay listed in each example's osv-scanner.toml.

What changed: correct the comments that said 1.81.16 was the first litellm to break Aider (it is 1.81.11), and add the CHANGELOG Security bullet. No lockfile or transcript changes.

How it was checked: osv-scanner scan source -r --no-ignore: 57 advisories before and after, 0 unignored after. bun run validate --skip mutations: all steps pass except langchain4j (no local Java); aider scripted passes with no change under examples/aider/runs.

Closes #526

fix(fallback): move a chain on from provider_overloaded and count it (5cf7b4a)

A fallback chain moved on from a rate limit, a quota error, an outage, a timeout and a lost sign-in, but not from a target the host reported as overloaded. Anthropic's overloaded_error (its 529) is mapped by toPlugError in src/session/events.ts to 503 provider_overloaded, and that code was in neither RETRYABLE nor COUNTED in src/fallback.ts. The client got the overload as the final error although the next target was healthy, and a provider that kept answering 529 never opened its circuit breaker.

What changed:

  • provider_overloaded is in RETRYABLE and in COUNTED (src/fallback.ts). src/breaker.ts needs no change: it is generic and takes whatever COUNTED lets through.
  • docs/configuration.md (retry rule and "What counts"), the models and aliases guide and docs/architecture.md list the code.
  • Tests: unit chain test (overload before output falls back, after output never does), the counted-failures table of the cooldown test, and a fault injection test over the fake host (host answers a 529; first target overloaded, second answers; three in a row open the breaker, then the target is skipped). Two mutation entries guard the two sets. Test-count floors regenerated.

Safety of the retry: a chain looks only at the first event of an attempt, so an overload that arrives after any text, reasoning or tool call is never retried. The overload code is only produced from a failed host step.

Validation: bun run validate --mutations --skip examples passes all 7 steps (lint, typecheck, test, manifest, pack, docs, mutations). The examples step fails locally only because Java is missing (langchain4j), as expected.

Closes #503

Docs (3)

docs(site): say what each added member of a full config is for (b756568)

The Full config tab adds keys and "models": "connected" when a snippet lacks them, marked // added so the file works. Readers could not tell what the lines were for. Each added member now says it: keys is one key per app that callers send as their API key, and models is which models apps may use (every model you are logged in to). Plugboard refuses to serve without either, which is why they are added.

Validation: bun test test/unit/docs-full-config.test.ts (24 pass); bun run docs:build completes.

docs(examples): use every example in the docs and check that it stays used (74bcfa5)

The client pages carried hand-written code that could drift from the example CI runs. 21 pages linked their example without importing it, and curl, n8n and tool-bot were in no page at all.

What changed:

  • SDK and framework pages (autogen, crewai, go-openai, langchain4j, langfuse, openai-dotnet, smolagents, spring-ai) import their snippets with <<< from the example. Regions went into the files CI runs: the runner for Go, .NET, langfuse and smolagents, the script itself for autogen and crewai, and the Java regions that already existed.
  • Config-only pages (aider, anythingllm, claude-code, codex-cli, continue, jan, kilo-code, librechat, lobechat, promptfoo, sillytavern, zed and the six self-hosted apps with files) import the example's config. JSON has no comment syntax, so kilo.json, settings.json, zed's settings.json and the n8n workflow are imported whole.
  • curl: the API pages (chat completions, responses, messages, gemini, completions, apis) and the tools guide import the curl example. n8n imports its compose file and one workflow. tool-bot goes into the tools guide as "A tool-calling bot".
  • The Go and .NET runners moved the observed-field assignments after the tool round trip, so the imported region holds only the round trip. The scripted runs are byte-identical.
  • examples-index.ts --check now fails when an example directory is named by no page of the site. Maintainer pages (the new docs/.vitepress/maintainer-pages.ts, shared with srcExclude) do not count, because the generated examples page lists everything. It also resolves whole-file <<< imports and region names followed by a language option ({ini}), which the old pattern read as part of the region name. The docs step of validate runs it.

Rejected alternatives: importing main.go and Program.cs for Go and .NET. CI never runs them, so the page would show code nothing checks. Importing the pom.xml dependency block, because the lockfile lane owns dependency pins, so the pages now name the artifact and link the pom.

No config-only example stays a bare link. PR-Agent's .secrets.toml and configuration.toml snippets stay inline because no example file holds them.

Validation was bun run validate --skip mutations: lint, typecheck, test, manifest, pack and docs pass. The examples step stops at langchain4j because Java is not installed ...

Read more