DEV Community

Roronoa
Roronoa

Posted on

Before Your First Mobile AI PR, Put a Meter Between the Phone and the Model

The DEV front page this week is full of confident "AI features that actually work" demos, and that is exactly the mood in which a new engineer breaks something. My first week on a mobile AI repo looked productive from the outside: simulator open, record button tapped, replies streaming in. What I never saw was the shared test key quietly shrinking behind each retry, and neither did the person paying for it.

On a server you can watch the bill climb. On a phone the damage is invisible: a screen that spins, an automatic retry you did not write, a backgrounded request that keeps streaming after you walked away. Nobody asks "what did that cost?" during onboarding, because nobody instrumented it. So make the meter your first PR, not your fourth.

Why a junior should own the meter, not the model

Quota exhaustion is the one failure class that does not throw. A crash gives you a stack trace and a teammate who has seen it before; a leaked token budget gives you silence until a demo fails in front of someone important. The person most likely to generate that traffic is the person with the freshest fingers on the record button.

Owning the meter forces you to read the request path end to end before you change it. You learn where the device id comes from, how cancellation propagates, what happens when the OS kills your socket, and which calls are yours versus the SDK's. That is a better first week than shipping a prompt tweak nobody asked for.

It also makes your later PRs reviewable. "This retry adds at most N metered calls per user action" is a sentence a senior can approve or reject. "Should be fine" is not.

The artifact: three small pieces

You need a proxy that logs usage, a client that never retries silently, and a ledger query you can run in one line. Nothing here requires production access, and nothing here needs a cloud budget if you use the free server option.

This is where MonkeyCode fits honestly. It offers free model access with an operator-stated 10 million token allocation, plus a free server option you can use to host the meter instead of asking for a staging VM on day two. I have not independently verified current quotas or terms, so check them yourself before you plan around them; the point is that a sandbox budget for onboarding is exactly what this measurement work needs.

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

1. The proxy (Node 20+, run it on the free server)

// meter/server.js
import express from "express";
import fs from "node:fs";

const app = express();
app.use(express.json({ limit: "1mb" }));

const LEDGER = "./ledger.jsonl";
const UPSTREAM = process.env.UPSTREAM_URL;
const UPSTREAM_KEY = process.env.UPSTREAM_KEY;
const DAY_CAP = Number(process.env.DAY_CAP ?? 20000); // estimated tokens, per device

// Deterministic and deliberately pessimistic. It is an estimate, never a receipt.
const estimateTokens = (text = "") => Math.ceil(text.length / 3);

app.post("/v1/chat", async (req, res) => {
  const { deviceId, requestId, messages } = req.body ?? {};
  if (!deviceId || !requestId || !Array.isArray(messages)) {
    return res.status(400).json({ error: "deviceId, requestId, messages required" });
  }

  const promptTokens = messages.reduce((n, m) => n + estimateTokens(m.content), 0);
  const startedAt = Date.now();
  const ac = new AbortController();
  req.on("close", () => ac.abort()); // the phone left: stop paying upstream

  try {
    const upstream = await fetch(UPSTREAM, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${UPSTREAM_KEY}` },
      body: JSON.stringify({ messages }),
      signal: ac.signal,
    });
    const json = await upstream.json();
    const text = json?.choices?.[0]?.message?.content ?? "";
    fs.appendFileSync(LEDGER, JSON.stringify({
      ts: new Date().toISOString(), deviceId, requestId,
      status: upstream.status, aborted: false, promptTokens,
      completionTokens: estimateTokens(text), ms: Date.now() - startedAt,
    }) + "\n");
    return res.json({ requestId, text });
  } catch (err) {
    fs.appendFileSync(LEDGER, JSON.stringify({
      ts: new Date().toISOString(), deviceId, requestId,
      status: "aborted_or_failed", aborted: ac.signal.aborted,
      promptTokens, completionTokens: 0, ms: Date.now() - startedAt,
      error: String(err?.name ?? err),
    }) + "\n");
    if (!res.headersSent) res.status(499).json({ requestId, error: "client_gone" });
  }
});

app.get("/v1/ledger/today", (_req, res) => {
  const today = new Date().toISOString().slice(0, 10);
  const rows = fs.existsSync(LEDGER)
    ? fs.readFileSync(LEDGER, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse)
    : [];
  const mine = rows.filter((r) => r.ts.startsWith(today));
  const estimatedTokens = mine.reduce((n, r) => n + r.promptTokens + r.completionTokens, 0);
  res.json({ requests: mine.length, estimatedTokens, cap: DAY_CAP });
});

app.listen(process.env.PORT ?? 8080);
Enter fullscreen mode Exit fullscreen mode

2. The client with no silent retry

// app/src/net/meteredChat.ts
type Msg = { role: "user" | "assistant"; content: string };
let seq = 0;

export async function meteredChat(messages: Msg[], deviceId: string) {
  const requestId = `${deviceId}-${Date.now()}-${++seq}`;
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), 15_000); // a ceiling, not a retry budget
  try {
    const res = await fetch(`${process.env.METER_URL}/v1/chat`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ deviceId, requestId, messages }),
      signal: ac.signal,
    });
    if (!res.ok) throw new Error(`meter_${res.status}`);
    return (await res.json()) as { requestId: string; text: string };
  } finally {
    clearTimeout(timer);
  }
}
Enter fullscreen mode Exit fullscreen mode

3. The one-line check

curl -s "$METER_URL/v1/ledger/today" | jq .
Enter fullscreen mode Exit fullscreen mode

Run it after every experiment. If requests grows while you are not touching the phone, you have found a background leak on your first week instead of your third month.

The lifecycle test plan you actually run

I have not run this on a physical device for this article, so the results column is yours to fill. Use a dev build that reads METER_URL from config, never a release build.

Condition Exact step Expected observation Your result
Airplane mode mid-stream Toggle airplane mode 2s after send One ledger row, aborted_or_failed, no upstream completion
Background 30s Press home during stream, wait, return Stream ends or resumes once; ledger never shows two completions for one requestId
Permission revoke Revoke microphone in Settings, relaunch No new request until permission restored; no queued replay
Process kill Swipe the app away mid-request Server logs client_gone once; zero retries on next launch
Low power mode Enable, repeat the same prompt Same request count; timing may differ, so record it
Network swap Wi-Fi to cellular mid-request Exactly one outcome, never a duplicate requestId

Record device, OS build, framework and dependency versions, permission state, network, and power state next to each row. If a row is empty, say it is empty; a missing measurement is data too.

When to use which setup

Situation Use
Learning the request path, need real model output Free server proxy plus free model access
Testing cancellation and timeouts only A local mock that returns slowly on purpose
Measuring battery or thermal cost On-device path, physical phone, charger state recorded
Anything with real user data Not this harness; talk to whoever owns the data policy

Limitations and who should skip this

Token counts here are character-based estimates, so they are useful for spotting leaks and useless for billing reconciliation. The proxy holds a single upstream key, so it is a development tool and never a public endpoint; add auth before it touches a shared network. And if your product ships inference fully on-device, a network meter solves a problem you do not have.

Skip this approach if you are already inside a metered platform with per-request accounting, or if your team's policy forbids routing prompts through an additional hop. In those cases, log locally and reconcile later.

If you try the lifecycle table, post the device, OS version, the transition steps, and whether the request recovered, restarted, or silently disappeared. Comparable evidence from one more phone is worth more than another demo that looks great on a simulator.

Top comments (0)