Skip to content

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:

  1. modelCalls: 0 β€” the refusal dispatched no provider request, so it billed nothing. This is the whole point.
  2. blocked, not a failure β€” the turn ends in the non-error terminal state; stepsStarted: 0 and no user/message reached the durable log.
  3. inboxTextAfterA β€” the prompt the user sent is still there, on the boundary the driver consumes first. A top-up is the only thing missing.
  4. 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). reserveAmount is 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 mode always, the retry happens inside a step that already passed this gate; agent/request-error cannot 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