Summary

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>.

Request flow

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

Shared balance (src/services/aiBalance.ts)

KeyHolds
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:walletsset of wallets with holds (for the sweeper)
  • What a charge may spend is s − sum(pending).
  • Accept runs in one Lua step, so two pods cannot spend the same last gas. A call id already held answers code 2.
  • Settle outcomes:
    • 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.
  • A re-read of iec-wallet writes 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.
  • Runtime setting 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.
  • Check also asks iec-wallet for the monthly app allowance once per org, program and month (a Redis marker), not on every call.

Charge outcomes

SituationAnswer
Fits the shared balance200 settlement: queued (or settled if no worker runs and the debit ran inline)
Does not fit402 INSUFFICIENT_GAS; an uncharged event is recorded
Call id already charged200 duplicate: true
Call id held by a request that has not recorded it yet503 AI_GAS_IN_PROGRESS (retryable)
iec-wallet unreachable and balance older than maxStaleMs503 AI_GAS_UNAVAILABLE
Redis unavailableDirect path: one synchronous debit, as before #64

Debit worker and sweeper

  • Worker (src/queues/ai-debit-queue.ts, src/services/aiDebit.ts):
    • concurrency AI_DEBIT_CONCURRENCY (default 10), 8 attempts with backoff;
    • job id ai-<eventId>, so a call id that becomes free again gets a new job;
    • the event moves to settled only on a conditional update, so a second run touches nothing;
    • emits Septor gas.ai_charged, which puts AI charges in the daily on-chain anchor (#70).
  • When every attempt fails:
    • a 402 from iec-wallet: the event becomes charged: false, reason insufficient_balance;
    • anything else: the charge stays charged: true, settlement: failed (owed), and its hold stays, because the caller was already told 200.
  • Sweeper (src/services/aiDebitSweeper.ts), runs every minute on one pod:
    • re-runs queued events not settled 2 minutes after they were accepted or last swept;
    • re-runs owed events every 5 minutes;
    • skips any event whose job is still live;
    • gives a charge up as settlement_abandoned (charged: false) after 24 hours;
    • releases holds older than 2 minutes that have no unpaid event.

Known gaps

  • Refused inline debit: the inline path (queue down) records a network failure as settlement_refused. iec-wallet may still have applied it, and it cannot be looked up yet (iec-wallet#88).
  • Sweeper snapshot race: the sweeper's list of holds is a snapshot, so it can release a fresh hold for a re-accepted call id (iec-janus#72).
  • Gateway and /i/ pre-checks still use the per-pod 5-second cache (iec-janus#63).
  • Redis reconnect: the Redis client stops reconnecting after 3 retries (iec-janus#61). Until that is fixed, a Redis outage leaves AI gas on the direct path until the pod restarts.

Background: Janus throughput and latency assessment.

Last updated: October 17, 2026