cordis-plugin-balance-guard
Verified@argszero/cordis-plugin-balance-guard Β· v0.1.0 Β· MIT
Balance guard for dsh: reads GET /user/balance on the agent/pre-step waterfall and refuses the step when the provider says the account cannot pay for it (is_available=false, or the balance is at/below a configured floor). The refusal dispatches no model r
Install
dsh plugin add @argszero/cordis-plugin-balance-guard Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
@argszero/cordis-plugin-balance-guard
Refuse the next model request when the provider says the account cannot pay for it β before the request is dispatched, so the refusal costs nothing.
A dsh plugin for @deepseek-ai/* (deepseek-harness). It answers the design
question in discussion
#7053, whose
companion bug report
#7052
describes the run that motivated it: ~20.8M input tokens spent, the account
ending at β$0.24, and no deliverable.
The gap
Nothing in the harness ever reads the account balance. On 0.1.6-alpha.2:
$ grep -rn "user/balance\|balance_infos\|is_available" packages/ --include=*.ts
$ echo $?
1
There is no maxCost, spendLimit, or per-session budget setting anywhere in
the configuration catalog either. A long run therefore discovers an exhausted
account the only way the provider allows β by being charged until it fails.
DeepSeek publishes the answer directly. GET /user/balance returns
is_available ("Whether the user's balance is sufficient for API calls") plus
balance_infos. This plugin reads that flag and refuses the step when it says
no.
The seam, and the trap in it
The guard listens on the public agent/pre-step waterfall. Its decision type
carries { kind: 'reject' }, and the agent loop maps a rejected step to
turn/end { kind: 'blocked' } β a non-error terminal state that every
consumer in the harness already understands (ACP codec, subagent lifecycle, web
projections). No request is built, no tool is dispatched, nothing is
half-executed.
The trap is that agent/pre-step runs after the inbox claim:
ReactLoopAgent.preStep() claims the batch first and passes it to the waterfall
as payload.messages. Returning reject without putting those messages back
would silently swallow the user's prompt. So a refusal restores every claimed
message first, through the public Inbox.prepend contract, preserving order and
skipping any id that is already pending (the inbox's durable fold rejects a
duplicate). This is the same shape goal-round-driver uses for the same reason.
Measured behaviour
Not a description of the code β the output of this repository's end-to-end proof
(e2e/), which mounts the packed tarball into the real harness: real
Context, SessionStore, AgentRegistry, AgentLoop, and a model adapter that
does nothing but count dispatches. Run on both ends of the declared range, with
identical results:
// phase A β the provider reports is_available=false
{ "balanceHits": 1, "modelCalls": 0,
"turnEndReasons": [{ "kind": "blocked" }],
"eventTypes": ["agent/inbox/spliced", "turn/start", "agent/inbox/spliced",
"agent/inbox/spliced", "turn/end"],
"durablyRecordedUserMessage": false, "stepsStarted": 0,
"inboxAfterA": 1,
"inboxTextAfterA": "and now spend the rest of the balance on this" }
// phase B β after a top-up, the surviving prompt runs
{ "modelCalls": 1, "firstRequestCarriedTheSurvivor": true, "inboxAfterB": 0,
"turnEndReasons": [{ "kind": "blocked" }, { "kind": "completed" }] }
Read it as four claims:
modelCalls: 0β the refusal dispatched no provider request, so it billed nothing. This is the whole point.blocked, not a failure β the turn ends in the non-error terminal state;stepsStarted: 0and nouser/messagereached the durable log.inboxTextAfterAβ the prompt the user sent is still there, on the boundary the driver consumes first. A top-up is the only thing missing.- phase B β the message that survived the refusal is the one the model receives afterwards. The stop loses no work.
Fail-open, bounded, cached
A guard on the critical path of every step has to be judged by how it fails. All three failure modes are closed:
| Failure | Behaviour |
|---|---|
| Endpoint unreachable, non-2xx, malformed body, missing credential, credential plane throws | Allow the request. "We do not know" is never "refuse". A guard that can stop work because a monitoring call failed is worse than no guard. |
| Endpoint hangs | Bounded twice: sampleTimeoutMs (default 3 s) and the step's own abort signal. |
| Every step paying for a sample | The sample is account-global and cached for sampleIntervalMs (default 60 s). Failures are cached for the same window, so a dead endpoint cannot add its timeout to every step of a turn. |
Configuration
| Key | Default | Meaning |
|---|---|---|
baseURL |
DEEPSEEK_BASE_URL layer, else https://api.deepseek.com |
Origin serving the balance endpoint β the same resolution llm-deepseek uses for its requests. |
balancePath |
/user/balance |
Resolved against the base's origin; a gateway prefix goes here (/prefix/user/balance), not in baseURL. |
apiKeyEnv |
DEEPSEEK_API_KEY |
Credential reference, resolved through the credentials service when the deployment has one, else through this run's environment layers. |
minBalance |
0 |
Refuse at or below this reported total. Raise it to keep a cushion. |
reserveAmount |
0 |
Held back from the reported total, standing in for spend committed since the sample. |
sampleIntervalMs |
60000 |
Sample validity window; 0 samples every step. |
sampleTimeoutMs |
3000 |
One read's deadline. |
onUnavailable |
reject |
warn records the verdict and never refuses (observability-only deployment). |
logSamples |
false |
Log every successful sample with its value and timestamp. |
Install
npm install @argszero/cordis-plugin-balance-guard
Mount it in a profile (the package ships a bundle patch):
- insert:
- id: balance-guard
name: '@argszero/cordis-plugin-balance-guard'
Tune it from your own layer:
- set:
- id: balance-guard
config:
minBalance: 5
reserveAmount: 2
logSamples: true
What this plugin deliberately does not do
- No cost forecasting. The gate is arithmetic β the provider's own go/no-go flag, plus an optional floor β not a prediction of what a task will cost.
- No per-request pricing. The full formula subtracts the price of the request
about to be sent; v0.1.0 does not price the next request (that would couple the
guard to token-meter routing).
reserveAmountis the manual stand-in. - No resumable state. Making an exhaustion stop resumable
(
awaiting_balance, resuming from the durable log instead of closing the turn) is a session-persistence property no plugin can add. What this plugin guarantees is that the stop costs nothing and that the user's input survives it. - No gate on
llm-retry's own retries. With retry modealways, the retry happens inside a step that already passed this gate;agent/request-errorcannot veto it from a plugin.
Compatibility
Declared peer range:
>=0.1.2-rc.1 <0.1.3 || >=0.1.3-alpha.2 <0.1.4 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0
The range is a claim, so it is measured rather than asserted. A seam sweep over
the published tarballs confirms that 0.1.2-rc.1 / 0.1.3-alpha.2 / 0.1.5-alpha.1 /
0.1.6-alpha.2 all export PreStepDecision with kind: 'reject',
Inbox.prepend, the agent/pre-step waterfall, and launchEnvironmentOf; the
end-to-end proof runs against the two ends of the range
(0.1.2-rc.1 and 0.1.6-alpha.2); and test/peer-range.spec.mjs computes the
declared range's real admitted set from the published version list, failing if it
drifts from the measured set. (A prerelease is admitted only by a comparator that
shares its major.minor.patch β which is why the range is four tuples and not
one <0.2.0 bound.)
Development
npm install
npm run build # tsc
npm test # unit + wiring + packaging + peer-range receipts
npm test also asserts that every bare import of the built artifact and of the
sources is declared by the manifest, and that no @deepseek-ai/dsh-* package is
a dependency (npm would happily nest a second copy of the harness runtime) β
the two directions of a packaging defect that a checkout-local test run cannot
see.
License
MIT