Services that spend model tokens for an org (jci-brain, jci-mcp, iec-portal, iec-browser, Cloys) report usage to Janus, and Janus is the only thing that moves the money:
POST /api/gas/ai/check, before a call: is the org allowed, may it use the model, can it pay.POST /api/gas/ai/charge, after a successful call: price the tokens (gas.ai.* settings) and charge.Since iec-janus#71 (17 Oct 2026, #64 and #70), both endpoints decide against a balance shared by every Janus pod in Redis, and the iec-wallet debit runs from a queue. Callers see the same contract as before: 200 means charged, 402 means insufficient, 503 means unavailable. jci-mcp delivers premium tools only on a 200.
Design decided 17 Oct: no leases or held balances in iec-wallet. Every call still gets one wallet debit and one on-chain burn, keyed ai-<org>-<callId>.
sequenceDiagram
participant C as Caller (jci-brain…)
participant J as Janus
participant R as Redis (shared balance)
participant M as Mongo gas_events
participant Q as BullMQ ai-gas-debit
participant W as iec-wallet
C->>J: POST /api/gas/ai/charge
J->>R: read balance (re-read W if older than freshMs)
J->>R: Lua accept: s - pending >= gas → hold callId
J->>M: insert event charged:true, settlement:queued
J->>Q: add job ai-<eventId>
J-->>C: 200 charged, settlement: queued
Q->>W: debit referenceId ai-<org>-<callId>
Q->>M: settlement: settled (once)
Q->>R: release hold, lower s, bump v
src/services/aiBalance.ts)| Key | Holds |
|---|---|
janus:aibal:<wallet> | hash: s (spendable read from iec-wallet), t (when read), v (settled-debit counter) |
janus:aipend:<wallet> | hash: <callId> → <gas>|<acceptedAtMs>, one entry per accepted, unpaid call |
janus:aipend:wallets | set of wallets with holds (for the sweeper) |
s − sum(pending).debited: free the hold, lower s, bump v;released: free the hold only;failed: free the hold and drop s;owed: keep the hold and drop s.s only if v did not change while the read was in flight, so a read that started before a debit can never add the spent gas back.gas.balanceCache has two values:
freshMs (default 5000): how often iec-wallet is re-read;maxStaleMs (default 30000): how long a stale value may be used while iec-wallet is unreachable. After that, Janus fails closed.| Situation | Answer |
|---|---|
| Fits the shared balance | 200 settlement: queued (or settled if no worker runs and the debit ran inline) |
| Does not fit | 402 INSUFFICIENT_GAS; an uncharged event is recorded |
| Call id already charged | 200 duplicate: true |
| Call id held by a request that has not recorded it yet | 503 AI_GAS_IN_PROGRESS (retryable) |
iec-wallet unreachable and balance older than maxStaleMs | 503 AI_GAS_UNAVAILABLE |
| Redis unavailable | Direct path: one synchronous debit, as before #64 |
src/queues/ai-debit-queue.ts, src/services/aiDebit.ts):
AI_DEBIT_CONCURRENCY (default 10), 8 attempts with backoff;ai-<eventId>, so a call id that becomes free again gets a new job;settled only on a conditional update, so a second run touches nothing;gas.ai_charged, which puts AI charges in the daily on-chain anchor (#70).charged: false, reason insufficient_balance;charged: true, settlement: failed (owed), and its hold stays, because the caller was already told 200.src/services/aiDebitSweeper.ts), runs every minute on one pod:
queued events not settled 2 minutes after they were accepted or last swept;settlement_abandoned (charged: false) after 24 hours;settlement_refused. iec-wallet may still have applied it, and it cannot be looked up yet (iec-wallet#88)./i/ pre-checks still use the per-pod 5-second cache (iec-janus#63).Background: Janus throughput and latency assessment.
Last updated: October 17, 2026