Repository navigation
Releases: ihsan-ramadhan/bansos-router
Release list
v0.5.0
What's Changed
A release about living with the daemon day to day: it can start at login, each agent can be set up with only the models you pick, DeepSeek tool calls stop failing, and a second bansos start no longer leaves two daemons fighting over the same state.
New Features & Enhancements
- Start at login (
bansos autostart on|off|status):- Linux uses a systemd user service, macOS a LaunchAgent, Windows a script in the Startup folder. On Linux and macOS a crashed daemon is restarted, while
bansos stopstill stops it for good. - A daemon that is already running is left alone and the service takes over at the next login.
offnever stops the running daemon. - The same switch is in the dashboard's Settings tab.
bansos doctorand the Health check show it, and flag it when the recorded command points at a path that no longer exists.
- Linux uses a systemd user service, macOS a LaunchAgent, Windows a script in the Startup folder. On Linux and macOS a crashed daemon is restarted, while
- Pick models per agent:
bansos setup <agent> --models a,band--upstream zen,kilowrite only the models you picked.- Agent Setup in the dashboard has a checklist per model and per upstream. A fully checked upstream keeps covering models that a later catalog refresh adds.
- Each agent gets the picker that matches how it takes models: a list, tiers (Claude Code), a single model, or discovery (9router, OMP).
Bug Fixes
- DeepSeek tool calls failed with 400
The reasoning_content in the thinking mode must be passed back to the API: the router strippedreasoning_contentfrom earlier turns. It is now kept on chat requests, and thinking from Claude Code (Anthropic) and Codex (Responses) is sent back asreasoning_content, or an empty string when there was none. Every model in the catalog was checked to accept the field. bansos doctorand the dashboard could hang the daemon (#22): 9router'sdata.sqlitewas read as text. It is now queried as SQLite, and the JSONC reader is linear, so a 1 MB config takes about 40 ms instead of 11 s.- Two daemons after a manual start:
bansos start --bg, which the pi/OMP extension also runs, started a second daemon on the next port when one was already up, splittingstate.jsonand the activity log between them. Every start path now checks/healthzfirst and printsbansosd already running at .... An explicit different--portstill starts a second one. - llm7 never went through the relay (#23): the allowlist held
https://llm7.io, but llm7 is called athttps://api.llm7.io, so its requests went direct without any error. - Restart: no more 409 after the port auto-bumps, a restart right after another one is no longer dropped, and
bansosd --bgreads the port from config instead of pinning it. - Strict mode leaves blocked upstreams out of
/v1/models, setup and the startup banner. Pinning a blocked model is rejected with 400 by the API; the CLI warns. - Secret redaction no longer reads an already-redacted marker plus the next line as a live credential when a nested JSON string contains escaped newlines.
- Windows: daemons are found with PowerShell
Get-CimInstance, sincewmicis gone in Windows 11 24H2.
Behavior Changes
bansos start,bansos start --bgandbansosdexit 0 with a message when a daemon already answers on the configured port.- With the relay enabled, llm7 traffic now goes through it. A self-hosted relay must allow
https://api.llm7.io, otherwise llm7 requests fail with the relay's error instead of going direct. - In strict mode
/v1/modelslists only allowed upstreams;?all=1returns everything.
Quality
- The suite runs against a temporary
BANSOS_DIRand no longer writes to~/.bansos(#24). Earlier test runs left fixture events in~/.bansos/activity.jsonunder the modelsmock/zen:free,strict-originandzen-origin; they can be deleted.
Contributors
- @ihsan-ramadhan
- @rai-pramana made their first contributions in #22, #23 and #24
Full Changelog: v0.4.1...v0.5.0
v0.4.1
What's Changed
A security and dashboard release: strict mode no longer locks up a session when a credential shows up, normal mode stops blocking pasted keys, and the daemon can now be configured, checked and debugged from the web dashboard.
New Features & Enhancements
- Strict mode redacts secrets instead of blocking (
src/security/secret-guard.ts):- API keys, PATs, private keys and credential assignments in a request bound for an external upstream are replaced with
[REDACTED:<type>]before it is sent. The rest of the message still goes through. - Blocking kept the secret in the session history, so every later request in that session failed with 422, compaction included. Redaction keeps the session usable while the secret still never leaves.
- Tool-call arguments (JSON inside a string) are redacted structurally, so the nested JSON stays valid, and a truncated PEM key is cut at the key body without eating the text after it. If anything is still detected after redaction, the request is blocked (fail closed).
- Set
"dlp": "block"to keep the previous 422 behavior.
- API keys, PATs, private keys and credential assignments in a request bound for an external upstream are replaced with
- Dashboard Settings, Health and Logs:
- Settings: security mode, secret handling (redact/block), allowed providers, cross-provider failover, catalog refresh, port and bind. Security changes apply immediately, other changes are flagged for restart, and there is a restart button.
- Health check: the same checks as
bansos doctor(daemon version, config validity, security in force, each coding agent's setup state). - Logs: the daemon log, live, with a filter, pause and copy.
- Agent Setup: apply or undo a harness config directly from the UI.
- Dashboard shows the security actually in force:
- The Models card shows real per-provider counts (
Zen 12 · Kilo 15 · …). In strict mode it counts only allowed providers and flags the rest as blocked. - Models outside the strict allowlist get a
blockedbadge in the model table, are sorted last, and are skipped by Ping / Ping All. - The Uptime card shows the failover / strict state, and
/bansos/statusnow reportsprovidersandsecurity.dlp.
- The Models card shows real per-provider counts (
- Config diagnostics:
security.modeis case-insensitive ("Strict"works).- An unrecognized
mode/dlpvalue, asecuritythat is not an object, or an invalidconfig.jsonis flagged in the daemon log,bansos doctorand the Health check. These cases used to fall back to normal mode silently.
Bug Fixes
- Normal mode blocked pasted credentials: since a96dfe8 the secret guard ran in every mode, so any request whose history held a key-like string got HTTP 422 in normal (default) mode. As documented, it now runs in strict mode only.
- The normal-mode test never exercised an external upstream: it ran against a loopback mock that is exempt from the guard. It now asserts that the body reaches an external upstream unchanged.
Behavior Changes
- Normal mode (the default) no longer scans or blocks secrets. Use
"mode": "strict"if you want them kept from leaving. - Strict mode redacts by default. Set
"dlp": "block"for the previous behavior. - A broken config file is reported but never overwritten from the dashboard, and strict mode cannot be enabled while the daemon is bound to a non-loopback address.
Contributors
Full Changelog: v0.4.0...v0.4.1
v0.4.0
What's Changed
A reliability release: every model and every harness (pi, OMP, opencode, Claude Code, Codex CLI) was re-tested end to end with tool calls, and the bugs that turned up are fixed here.
New Features & Enhancements
- Zen tool bridge (
src/protocols/tool-bridge.ts):- The Zen gate injects
read/shelltools. When a model called one of those instead of the harness's own tool, bansos used to hide the call and the turn ended empty. That call is now re-emitted as the caller's equivalent tool (Bash,bash,exec_command,Read,read_file, …), with arguments mapped from the target's JSON schema. Harnesses without a reader (Codex) getreadas a quotedsed -nover their shell tool. - The mapping fails safe: if a required field cannot be filled, the call stays hidden as before. The model is shown its original call when the history comes back, so it does not loop.
- Zen models in Codex, OMP, and Claude Code now finish file + shell tasks that previously ended empty.
- The Zen gate injects
- Capability-aware routing:
- Models that cannot take tools (from Kilo
supported_parameters/ LLM7capabilities.tools) or images are skipped for requests that need them./v1/modelsnow reportssupports_tools. - Upstream errors such as
does not support toolsorNo endpoints found that support tool usetrigger failover when it is enabled. When failover is off, the upstream error is returned as-is: tools are never silently dropped.
- Models that cannot take tools (from Kilo
- pi / OMP extension:
- Model names carry their upstream (
Big Pickle (Zen),Qwen3.5 9B (OVH)). - The live catalog is cached in
~/.bansos/pi-models.jsonand used when the daemon is unreachable. The bundled fallback list has been refreshed (it still listed retired models). - Vision models now accept image input.
reasoningcomes from the daemon instead of being guessed from the model id.
- Model names carry their upstream (
Bug Fixes
- **
/v1/responsesstreams never returned tool calls (Codex CLI coeam encoder ignoreddelta.tool_calls. It now emitsfunction_callitems withfunction_call_arguments.*events, one item per output index. - Injected Zen tools leaked: the Zen transform pushed
read/shellinto the caller's owntoolsarray. As a result they counted as caller tools (Codex loggedunsupported call: read) and were sent on to whichever upstto. - Relay could be repointed by any website (CSRF):
POST /bansos/relayaccepted cross-sitetext/plainrequests. POSTs with a cross-siteOriginare now rejected with 403. - Errors inside HTTP 200 streams were swallowed: Zen's
Streamid Kilo/OpenRouter error frames (sent after keepalives) became emptyreplies. They now fail over, or surface as a 502, an Anthropicerrorevent, orresponse.failed`. bansos setup codex/jcodewrote invalid TOML: root keys leftmodel_providerpointing at a deleted table. Root keys arenow tagged# bansos-router, and a value you already had is restored on undo.- opencode v2 rejected streams ("content after the finish reason"): the reasoning fold was sent after the finish frame, even after tool calls.
- Non-ASCII text corrupted in streams: multibyte characters split across chunks became
�. CRLF-delimited events no longer wait for the stream to end. - Anthropic
/v1/messages:input_tokenswas always 0, so Claude Code's auto-compact never triggered.- Content blocks overlapped (text after a tool call went into a c
- Separate text blocks were glued together, and
is_erroron too - 429 was reported as
invalid_request_errorinstead ofrate_limit_error.
/v1/responsesinput:- Images were inlined as base64 text.
- Replayed
reasoningitems returned 400. - Parallel function calls were split into separate assistant messages.
- Empty upstream streams produced event orders the Anthropic and OpenAI SDKs reject.
- Reasoning shown as the final answer after a model called a to
- Setup reruns:
- Stale models piled up in opencode / openclaw / continue, and Goose duplicated its list on every run.
--undo continueremoved nothing.- The JSONC parser rewrote string contents in user configs.
- Rate-limit hints that only appear in the body (LLM7 `Retry afooldown.
- OMP rejected requests with
400 unknown field "__bansosToolAliases".
Behavior Changes
- Cross-site browser POSTs to the daemon return 403. Native clients send no
Originheader, and the dashboard is same-origin, so neither is affected. bansos setup codex/jcodetag root keys with# bansos-routerand keep your previous value as a# bansos-router-prev:comment.- Re-running
bansos setupreplaces the bansos model list instead
Contributors
Full Changelog: v0.3.3...v0.4.0
v0.3.3
What's Changed
New Features & Enhancements
- OVHcloud AI Endpoints upstream (
src/upstreams/ovh.ts):- New keyless upstream adding
ovh/Qwen3.5-397B-A17B,Qwen3.5-9B,Qwen3.6-27B,Qwen3.8-27B,Meta-Llama-3_3-70B-Instruct,Mistral-Small-3.2-24B-Instruct-2506, andgpt-oss-20b/gpt-oss-120b.
- New keyless upstream adding
- OMP (Oh My Pi) harness support (
bansos setup omp, #21):- Writes a
bansosprovider into~/.omp/agent/models.ymlvia a YAML-block merge that nests it underproviders:at the sibling indent, creating the key when absent. Reversible withbansos setup omp --undo. - Also loadable as the pi extension (drop
extensions/pi/dist/index.jsinto~/.omp/agent/extensions/): registers abansosrprovider and auto-manages the daemon. See README for both paths.
- Writes a
Bug Fixes
- vLLM thinking controls rejected by strict upstreams (
src/protocols/openai-chat.ts):- OMP sends
preserve_thinking/chat_template_kwargs/enable_thinking, which strict upstreams (OVHcloud) reject with400 "extra arguments". The daemon now strips these from the outbound body.
- OMP sends
- Paid pro-tier models listed as free (
src/upstreams/llm7.ts):- LLM7 flags tier
prowithusage_based_only: false, and those402at the gateway. They are now excluded from the catalog regardless of the usage flag.
- LLM7 flags tier
- Empty HTTP 200 when an upstream overloads (
src/daemon/server.ts):- Some upstreams (Kilo → Nvidia overloaded) answer
200with an error envelope and nochoices, which surfaced as an empty reply. The daemon now treats that as a transient failure and fails over.
- Some upstreams (Kilo → Nvidia overloaded) answer
Contributors
- @ihsan-ramadhan
- @willy-scr (OMP harness support, #21)
Full Changelog: v0.3.2...v0.3.3
v0.3.2
What's Changed
Enhancements
- OpenCode Zen v2 client realignment (
src/upstreams/zen.ts):- Rebuilt the spoofed client identity to match OpenCode's current desktop client: updated User-Agent (
opencode/1.18.31), refreshedx-opencode-*headers, and session/request-id generators that follow OpenCode'sses_/request-id format. - Split the endpoints the upstream talks to: inference now goes to
/inference/v1while the catalog is still read from/zen/v1/models. - The spoofed User-Agent can be overridden with the
BANSOS_OPENCODE_UAenv var, so the client version can be bumped without a code change when OpenCode moves again. - Reworked the chat and responses request-body transforms and refreshed the pinned free-model seed.
- Rebuilt the spoofed client identity to match OpenCode's current desktop client: updated User-Agent (
Contributors
- @ihsan-ramadhan
- @dependabot (#16, #17, #19, #20)
Full Changelog: v0.3.1...v0.3.2
v0.3.1
What's Changed
New Features & Enhancements
- Dynamic Zen Catalog from models.dev (
src/upstreams/zen.ts):- Build the Zen catalog from the intersection of the models.dev
opencodeindex and Zen's live/v1/modelslisting, instead of filtering a hardcoded seed. - New free Zen models now enter the catalog automatically; models that turn paid or get marked
deprecateddrop out without a code change. - Model facts (name, reasoning, context window, output limit, modalities) are read from models.dev. A field the index omits falls back to the matching
ZEN_MODELSentry before the generic default. - The seed is no longer the master list. It now only supplies
wireApi, fallback metadata, and the ids probed keyless when Zen's listing omits them. - A models.dev outage degrades the pass instead of wiping the catalog: a rejected fetch (DNS failure, refused connection, timeout) is handled them.
- A models.dev outage degrades the pass instead of wiping the catalog: a rejected fetch (DNS failure, refused connection, timeout) is handled the same as a non-ok status.
- Build the Zen catalog from the intersection of the models.dev
- PDF Input on the Responses Wire (
src/protocols/responses-upstream.ts):- Chat
fileparts are translated toinput_fileon the way out, keepingfile_id/file_data/filename. pdfis advertised only for responses-wire models and filtered out for chat-wire ones, because Zen answers HTTP 500 for afilepart on/chat/completions./chat/completions.
- Chat
- Injected Tool Filtering (
src/daemon/server.ts):- The spoofed
read/shelltool definitions required by Zen's free tier are stripped from both streaming and folded responses when the caller never declared them. finish_reasonis rewritten fromtool_callstostopwhen every call in the reply was injected, so downstream harnesses do not wait on a tool result they never asked for.- The Zen responses wire rejects
tool_choice: "none", so it is filtered outbound.
- The spoofed
Bug Fixes
- Failover could pick a model smaller than the origin (
src/daemon/server.ts):- Candidates were ranked by absolute context distance, so a 190k model beat a 256k one for a 200k origin. A near-full-context request that failed over then hit a context-length 400. Candidates that cover the origin's context are now preferred as a group; the 90% tolerance band is only reached for when nothing larger exists.
- A seeded model that turned paid stayed in the catalog (
src/upstreams/zen.ts):- The free/paid filter was bypassed for seeded ids, so a model flipping from free to paid kept being offered and failed with 401/402 at the gateway. Paid ids are now probed like deprecated ones.
- A models.dev outage wiped the Zen catalog (
src/upstreams/zen.ts):- The index fetch shared a
Promise.allwith Zen's listing, so a rejection (not just a non-ok status) aborted the whole pass and left Zen contributing zero models. - Unread response bodies on degraded refresh paths are now cancelled instead of held open until GC.
- The index fetch shared a
- Published limits were lost when models.dev omitted them (
src/upstreams/zen.ts):- Muse Spark fell back to a generic 128k / 16k default, which dropped it out of Nemotron's failover band and shifted default-model ranking.
Tests & Quality
- Added unit tests for Zen catalog assembly (
test/zen-catalog.test.ts), injected tool filtering (test/tool-calls.test.ts), PDF passthrough (test/responses-upstream.test.ts), and failover ranking (test/failover.test.ts). - Test suite is now 160 passing (was 127).
- Dependencies:
@types/nodeto 26.5.1,lucide-preactto 1.45.0, andviteto 8.3.0 (#12), plus@types/nodeinextensions/pi(#11). All dev-only; the published package still ships zero runtime dependencies.
Contributors
Full Changelog: v0.3.0...v0.3.1
v0.3.0
What's Changed
Enhancements
- OpenCode Zen v2 Upstream Spoofing & Gateway Bypass:
- Update spoofed client identity to OpenCode v2.0.5 (
opencode/latest/2.0.5/cliwithauthorization: Bearer public). - Implement official OpenCode descending TypeID generator (
ses_+ 48-bit inverted timestamp hex + Base62 random suffix). - Inject required session affinity and tracing headers (
x-session-affinity,x-session-id,x-opencode-project,b3,traceparent). - Bypass free-tier gate payload restrictions: automatically inject core tool definitions (
readandshell) withtool_choice: "none"for plain text completions. - Introduce
transformRequestBodyhook inUpstreaminterface for upstream-specific payload mutations. - Transparently aggregate SSE stream chunks into standard JSON responses when downstream clients request non-streaming completions (
stream: false).
- Update spoofed client identity to OpenCode v2.0.5 (
v0.2.6
What's Changed
Bug Fixes
- Reported in #10: a
403from an upstream could wedge the router.401/403was treated as a terminal error, so failover stopped dead and the request returned the rejection even when healthy models were available. Because Muse Spark is the default model, a deployment where OpenCode Zen refuses the egress (Cloudflare can route through a region its providers block, answering403 This service is not available in your region) hit the same wall on every request. Now a refusal fails over to another model and parks the refused one for 30 minutes, so later requests skip it entirely. Retrying cannot help a policy refusal, but it is not permanent either: egress routing changes on its own. Sendx-bansos-no-failover: 1when you want the model's own status back. - The upstream's own error message is now logged. A rejection used to record only
status=403, which told you nothing about why. The upstream's explanation is logged alongside it (redacted in strict mode, and scanned for credentials as every log field is). - The Web UI playground showed nothing on the Anthropic and Responses protocols. Its SSE reader only accepted frames beginning with
data:, but those two protocols lead with anevent:line, so every frame was discarded before parsing. Chat Completions was unaffected, which is why the daemon logs showed tokens flowing while the screen stayed empty. A second bug sat underneath: the Responses extractor looked for three event names the encoder never emits. - A reasoning-only model returned an empty reply on the Anthropic wire. The encoder emitted a
thinkingblock and no text block at all, so Claude Code received a message with nothing to display. It now promotes the reasoning to the answer, matching what the Chat and Responses paths already did.
Behavior Changes
-
Reasoning no longer leaks into the answer. The Chat path used to rewrite a reasoning-only delta into
contentand dropreasoning_contententirely, and the Responses encoder sent reasoning asoutput_text.delta. A model that thinks first and answers second therefore had its internal monologue prepended to its reply on both wires. Reasoning now passes through in its own field (reasoning_contentfor Chat,response.reasoning_summary_text.deltafor Responses), and is promoted to the answer only at the end of the stream, and only when the model produced no content of its own.The trade: for a model that never emits content, a client that ignores reasoning now sees the text arrive in one piece when the stream closes, instead of streaming from the first token. Clients that render reasoning (the playground, Claude Code, opencode) are unaffected. All three wire protocols now produce identical content for the same upstream stream, which is covered by a test.
Contributors
- @ihsan-ramadhan
- @bagusnaufal2 (reported #10)
Full Changelog: v0.2.5...v0.2.6
v0.2.5
What's Changed
New Features & Enhancements
- Muse Spark, through a second upstream wire: Muse answers only on
/v1/responsesand returns HTTP 500 on chat completions, so a model can now carrywireApi: "responses". The daemon translates chat to Responses on the way out and back again on the way in, streaming and tool calls included, so chat, Anthropic, and Responses clients all keep their usual format. Addsmuse-spark-1.3-contributor-freeandmuse-spark-1.2-contributor-free. - Muse Spark 1.3 is the new default model for
bansos setup. Capacity ranking alone could not select it (its nominal context is smaller than Inkling's), so there is now an explicit shortlist that wins whenever the model is alive. - Rate-limited models are parked: a model that answers
429goes on cooldown, so the next request starts on a fallback instead of spending another round trip to learn the same thing.Retry-Aftersets the duration when the upstream sends one, otherwise a minute, capped at fifteen. Parked models stay listed in/v1/modelsand are still used when nothing else qualifies. - Catalog refresh runs in parallel: the three upstreams are queried together instead of one after another. Measured 3.5s to 0.85s on a live daemon.
bansos stopis scoped by default — a behavior change. It now stops the daemon recorded instate.jsoninstead of every bansos daemon on the machine, so a daemon started elsewhere is left alone. Usebansos stop --allfor the old behavior.
Bug Fixes
- Four dead models removed, each confirmed with a live request rather than by absence from a listing:
laguna-s-2.1-free(401 not supported),gpt-oss(400 unavailable), andminimax/minimax-m3:free/minimax/minimax-m2.7:free(404 on the Kilo gateway).inclusionai/ling-3.0-flash-sante:freeis seeded in their place. muse-spark-1.2-contributor-freeis back. v0.2.4 listed it as retired, but it was healthy the whole time: the health check had been probing it on the wrong wire.- Responses-wire streaming could take the daemon down: an upstream reset arrived as an unhandled stream error, which Node turns into an uncaught exception. The upstream leg now goes through
stream.pipeline, which also releases the upstream socket when a client disconnects. - Failover log named the wrong upstream: the "fallback used" warning read
fromUpstreamafter it had already advanced to the fallback, so a Zen model was reported as coming from Kilo.
Contributors
Full Changelog: v0.2.4...v0.2.5
v0.2.4
What's Changed
New Features & Enhancements
- Always-on network hardening: SSRF blocklist for internal IP ranges (LAN, loopback, link-local/metadata, CGNAT), DNS-rebinding guard on the
Hostheader, loopback-only CORS instead of a wildcard, plusnosniff/DENY/CSP headers. Active in normal mode, not just strict. - Zero runtime dependencies: dropped
smol-tomlandyaml; the CLI now installs with no transitive runtime packages. - Log rotation:
bansosd.logrotates at 5 MB keeping 2 backups, so a long-running daemon never grows one unbounded file. - Reasoning fallback on streams: upstreams that stream only
reasoningnow get it folded intocontent, so clients never receive an empty response. - Client disconnect aborts upstream work instead of letting the request finish in the background.
- CLI follows the live port:
setup,ping,doctor,relay,status, andmodelsread the bound port fromstate.json, so they still work after an auto-bump. - Catalog synced with live upstreams: added MiniMax M2.7, Ling 3.0 Flash Fin, Dots 3 Note Preview, Inkling, and Inkling Small; retired
tencent/hy3:free,DeepSeek-V4-Flash-0731,gemini-3.1-flash-lite, andmuse-spark-1.2-contributor-free. - Activity events now include the HTTP status code, shown in the Web UI and
bansos logs --activity.
Bug Fixes
- Duplicate lines in
bansos logs: the--bgchild wrote the log file twice. Completes the partial fix from v0.2.3. bansos start --bgprinted the wrong port:state.jsonwas written after the first catalog refresh, past the 5s window the parent waits. It is now published as soon as the socket is listening.- Refused bind was silent and exited 0: under strict mode a non-loopback bind failed with an empty log and a success exit code. The error now reaches
bansosd.logand the exit code is non-zero. - pi extension was pinned to port 17070: it ignored
state.json, so after an auto-bump it registered a provider pointing at a dead port and silently fell back to a stale model list. It now resolves the live host and port. - pi extension killed every daemon on quit: it ran
bansos stop, which stops every daemon on the machine. It now stops only the one it started.
Contributors
Full Changelog: v0.2.3...v0.2.4