DEV Community

Avery Lin
Avery Lin

Posted on

Build a Spend Breaker Before You Ship AI

Build a Spend Breaker Before You Ship AI

A solo founder ships one AI button on a Friday afternoon. The button turns a messy customer note into three tidy bullets. The demo lands, the weekend starts, and nobody watches the endpoint.

By Monday morning that endpoint has answered tens of thousands of calls. A worker retry loop kept hammering a timeout. The free allowance is gone, and every request now costs real money.

The numbers in that story are illustrative. The shape of the failure is not. A free tier never removes the budget; it changes the unit from dollars to tokens and CPU.

Arguments about AI fatigue keep circling the feed this week. The quieter question is who pays when a shipped AI feature misbehaves at three in the morning. For a solo build, the answer has to live in code, not in attention.

The loop that eats a free tier

Most first versions look like this.

// worker.mjs — the version that eats a weekend
async function summarize(note) {
  while (true) {
    try {
      return await callModel(note);
    } catch {
      // retry forever, because "free"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That loop has no governor. A timeout, a 429, or a slow backend turns one user action into thousands of billed calls. Providers are happy to keep answering until the quota runs out, then charge or refuse.

MonkeyCode is an open-source project that lists free model access and a free server option, both supplied by the operator. The free token allowance is currently advertised at 10 million tokens. Verify the project's current terms before you depend on any number, because allowances and limits change.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

A free allowance is still a budget. It just spends tokens instead of dollars, and it refills on someone else's schedule. So the breaker counts tokens first and money second. That order matters on day one of any indie build.

Three pieces: ledger, gate, settle

The breaker is small and deliberately boring. One append-only ledger, one gate before the call, one settle after it.

// spend.mjs
import { appendFileSync, readFileSync, existsSync } from "node:fs";

const LEDGER = process.env.LEDGER ?? "./spend.jsonl";
const TOKEN_BUDGET = Number(process.env.TOKEN_BUDGET ?? "200000");
const USD_BUDGET = Number(process.env.USD_BUDGET ?? "0");
const EST_OUT = Number(process.env.EST_OUT ?? "256");

export function estimateTokens(text) {
  // Rough heuristic: about four characters per token for English prose.
  // Treat this as a guess and settle with provider-reported usage.
  return Math.ceil(String(text).length / 4);
}

export function spendToday(now = new Date()) {
  const day = now.toISOString().slice(0, 10);
  const totals = { day, in: 0, out: 0, usd: 0, calls: 0 };
  if (!existsSync(LEDGER)) return totals;
  for (const line of readFileSync(LEDGER, "utf8").split("\n")) {
    if (!line.trim()) continue;
    let row;
    try { row = JSON.parse(line); } catch { continue; }
    if (!String(row.ts).startsWith(day)) continue;
    totals.in += row.in ?? 0;
    totals.out += row.out ?? 0;
    totals.usd += row.usd ?? 0;
    totals.calls += 1;
  }
  return totals;
}

export function gate(text, now = new Date()) {
  const estIn = estimateTokens(text);
  const s = spendToday(now);
  const projected = s.in + s.out + estIn + EST_OUT;
  if (projected >= TOKEN_BUDGET * 0.9) {
    return { ok: false, reason: "token-budget", estIn, ...s };
  }
  if (USD_BUDGET > 0 && s.usd >= USD_BUDGET) {
    return { ok: false, reason: "usd-budget", estIn, ...s };
  }
  return { ok: true, estIn, ...s };
}

export function settle(row, now = new Date()) {
  const record = { ts: now.toISOString(), ...row };
  appendFileSync(LEDGER, JSON.stringify(record) + "\n");
  return record;
}
Enter fullscreen mode Exit fullscreen mode

The gate closes at ninety percent, not at one hundred. A closed gate still has to answer the user, so the wrapper ships a deterministic fallback instead of an error page.

// guard.mjs
import { gate, settle } from "./spend.mjs";

export function cheapFallback(note) {
  // No model call. Boring, instant, always available.
  const first = String(note).split(/[.!?]\s/)[0] ?? "";
  return first.slice(0, 120) || "Summary unavailable.";
}

export async function guardedSummarize(note, { callModel, now = new Date() }) {
  const verdict = gate(note, now);
  if (!verdict.ok) {
    settle({ route: "fallback", in: 0, out: 0, usd: 0, reason: verdict.reason }, now);
    return { text: cheapFallback(note), route: "fallback", reason: verdict.reason };
  }
  try {
    const res = await callModel(note);
    settle({
      route: "model",
      in: res.usage?.input_tokens ?? verdict.estIn,
      out: res.usage?.output_tokens ?? 0,
      usd: res.usage?.cost_usd ?? 0,
    }, now);
    return { text: res.text, route: "model" };
  } catch (err) {
    // Failed attempts usually still consume input tokens.
    settle({ route: "error", in: verdict.estIn, out: 0, usd: 0, error: String(err) }, now);
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

The model call itself stays behind a thin adapter. Any OpenAI-compatible endpoint works, so the breaker never locks a solo build to one vendor.

// model.mjs — OpenAI-compatible adapter
export function makeCaller({ baseUrl, apiKey, model }) {
  return async function callModel(note) {
    const res = await fetch(`${baseUrl}/chat/completions`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${apiKey}`,
      },
      body: JSON.stringify({
        model,
        messages: [{ role: "user", content: `Summarize in 3 bullets:\n${note}` }],
        max_tokens: 256,
      }),
    });
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    const json = await res.json();
    return {
      text: json.choices?.[0]?.message?.content ?? "",
      usage: {
        input_tokens: json.usage?.prompt_tokens ?? 0,
        output_tokens: json.usage?.completion_tokens ?? 0,
      },
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

Read baseUrl, apiKey, and model from environment variables filled in from the project's own setup page. Never hardcode a key into a repo that a free host will clone.

A test that proves the gate closes

The breaker is worth nothing if it is only read and never run. Here is a fully offline test with a stubbed model and an explicit clock.

// spend.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "spend-"));
process.env.LEDGER = join(dir, "spend.jsonl");
process.env.TOKEN_BUDGET = "1000";
process.env.USD_BUDGET = "0";

const { guardedSummarize } = await import("./guard.mjs");
const { spendToday } = await import("./spend.mjs");

const callModel = async () => ({
  text: "ok",
  usage: { input_tokens: 40, output_tokens: 60 },
});

const note = "short note about a late invoice. ".repeat(4);

test("the gate closes before the budget is spent", async () => {
  const now = new Date("2026-10-16T09:00:00Z");
  const routes = [];
  for (let i = 0; i < 40; i++) {
    const out = await guardedSummarize(note, { callModel, now });
    routes.push(out.route);
  }
  const modelCalls = routes.filter((r) => r === "model").length;
  const total = spendToday(now);
  assert.ok(modelCalls > 0, "at least one model call reached the endpoint");
  assert.ok(total.in + total.out < 1000, "stayed under the hard token budget");
  assert.equal(routes.at(-1), "fallback", "the gate closes and stays closed");
});

test("a new day reopens the gate", async () => {
  const day2 = new Date("2026-10-17T09:00:00Z");
  const out = await guardedSummarize(note, { callModel, now: day2 });
  assert.equal(out.route, "model");
});
Enter fullscreen mode Exit fullscreen mode

Run it with the budget in the environment, so the numbers stay visible and easy to change.

LEDGER=/tmp/spend.jsonl TOKEN_BUDGET=1000 node --test spend.test.mjs
Enter fullscreen mode Exit fullscreen mode

The first test loops forty times and still finishes in a few milliseconds. That is the point of an offline stub. The failure you fear took someone a weekend; here it takes a test run.

Where the free server fits

Run the worker and the ledger in the same process, on the same small host. The free server option is a reasonable place for exactly that shape: one Node process, one SQLite or JSONL file, one external model endpoint.

Keep the ledger path inside the app directory, not /tmp. A restart should not silently refund the day's spend. Limit the process to a single instance as well, because two workers writing the same file can double-count or lose rows.

Free hosts impose limits of their own. Read the current ones before you promise a customer anything, and decide what a cold or throttled start does to your fallback path.

A routing matrix for the first month

Situation Route Why
One user click, user is waiting Model call, gated Latency is acceptable, one call per action
Background job over many rows Batch with a per-run cap Runaway loops start in workers, not in UI
Gate already closed Deterministic fallback Ship a worse answer, not an outage
Provider returns 429 or 5xx Two retries, then fallback Unbounded retries multiply token spend
Accounting must be exact Neither route Use a billing system, not a JSONL file

Limitations and who should skip this

The ledger is per-process and file-based. Fine for one solo app, wrong for anything running multiple regions. The ninety percent threshold is a guess that you should tune against real traffic. The token estimate drifts from provider counts, so settle with reported usage whenever the endpoint returns it.

Above all, a breaker protects the allowance, not correctness. A bad prompt still returns bad summaries under budget. And free model access, like any free tier, is a moving target; check the current terms before you build a business rule on top of it.

Skip this approach if you already have provider-side quotas and a billing dashboard. Skip it for high-throughput services where a gateway belongs in front of every call. Skip it if you cannot read and re-read the provider's limits. A solo build with one AI button is the sweet spot, because a JSONL file and forty lines of Node buy you the one guarantee that matters: the weekend stays boring.

If you want to run this loop yourself, MonkeyCode's free model access and free server option are a cheap place to start. Bring the test file, not the feature.

Top comments (0)