<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Cuong Duong</title>
    <description>The latest articles on DEV Community by Cuong Duong (@cuong_duong_566f34ed73efc).</description>
    <link>https://dev.to/cuong_duong_566f34ed73efc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4111950%2Fe6132f07-a166-4709-a975-5d14346b6e60.png</url>
      <title>DEV Community: Cuong Duong</title>
      <link>https://dev.to/cuong_duong_566f34ed73efc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cuong_duong_566f34ed73efc"/>
    <language>en</language>
    <item>
      <title>Two ways usage-based AI billing quietly loses money</title>
      <dc:creator>Cuong Duong</dc:creator>
      <pubDate>Sun, 06 Sep 2026 07:12:08 +0000</pubDate>
      <link>https://dev.to/cuong_duong_566f34ed73efc/two-ways-usage-based-ai-billing-quietly-loses-money-2g00</link>
      <guid>https://dev.to/cuong_duong_566f34ed73efc/two-ways-usage-based-ai-billing-quietly-loses-money-2g00</guid>
      <description>&lt;p&gt;If you charge for tokens, two bugs will take real money off you before any customer notices: charging &lt;em&gt;before&lt;/em&gt; you know the token count, and charging twice when Stripe retries a webhook. The fixes are small — meter after the stream closes using the provider's own usage numbers, and make the ledger reject duplicates at the database level instead of in your code.&lt;/p&gt;

&lt;p&gt;Both bugs are invisible in testing. Streams complete, webhooks arrive once, and the numbers look right. They show up in production, on the requests that get cancelled and the webhooks that get redelivered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: metering before the answer exists
&lt;/h2&gt;

&lt;p&gt;The tempting order is to estimate cost up front, deduct, then stream. It's tempting because the balance check is already there and an estimate feels close enough. It isn't: output length varies by an order of magnitude on the same prompt, and the moment you add a model with different pricing, every estimate silently becomes wrong.&lt;/p&gt;

&lt;p&gt;Do it the other way around. Check the balance before the call — a cheap gate, not a charge — then stream, then read the usage the provider reports on the final event and meter that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// gate, don't charge&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;payWall&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;final&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finalMessage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;output_tokens&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;final&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ceilToCredit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;input_tokens&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inPrice&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="nx"&gt;output_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outPrice&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// after, with real numbers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Round &lt;em&gt;up&lt;/em&gt; to your credit unit, and store credits as integers. Floating-point money is a slow leak that only shows up when you reconcile at month end, and a credit worth a tenth of a cent makes rounding up honest rather than greedy.&lt;/p&gt;

&lt;p&gt;Two edge cases decide whether this holds: if the client disconnects mid-stream you still owe the provider for the tokens produced, so meter what was generated, not what was delivered; and if the metering call itself fails after a successful response, write the pending charge somewhere durable and retry it, rather than dropping it and calling that customer service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: the webhook that arrives twice
&lt;/h2&gt;

&lt;p&gt;Stripe retries a webhook whenever it doesn't get a fast 2xx — a timeout, a deploy, a cold start. If a retry runs your "add credits" handler again, the customer gets a second top-up for one payment. The version of this that hurts is the one nobody reports.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;if (alreadyProcessed)&lt;/code&gt; check in application code doesn't fix it, because two retries can run concurrently and both read "no" before either writes. Push the guarantee down to the database: make the payment's own identifier a unique key on the ledger.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- one row per checkout session, enforced by the database&lt;/span&gt;
    &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;credit_ledger_session_uniq&lt;/span&gt;
      &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;credit_ledger&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stripe_checkout_session_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;-- handler&lt;/span&gt;
    &lt;span class="n"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creditLedger&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isUniqueViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;already&lt;/span&gt; &lt;span class="n"&gt;credited&lt;/span&gt;
      &lt;span class="n"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The duplicate now fails at insert time, under a lock the database is already taking, and you answer 200 so Stripe stops retrying. Same shape works for the reverse direction: one row per metered request, so a retried deduction can't double-charge either.&lt;/p&gt;

&lt;p&gt;While you're there, check the concurrency case on spending too. Fire five requests at once against a balance of one and the naive read-then-deduct path lets all five through. A row lock on the balance (&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;), or a single in-flight request per user, closes it — worth doing before you have the kind of customer who finds it on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it pays, and where it doesn't
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Worth doing
&lt;/h3&gt;

&lt;p&gt;Any product where the marginal cost of a request is real money and users control how much they consume. The two fixes together are an afternoon; one month of a heavy user on estimated billing costs more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not worth it
&lt;/h3&gt;

&lt;p&gt;Flat-rate subscriptions with generous limits, or an internal tool. If nobody is billed per token, a token counter is telemetry, not accounting — keep it in your logs and skip the ledger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off.&lt;/strong&gt; Metering after the fact means a user can overshoot a near-zero balance by one request — accepted deliberately: refusing to answer mid-stream is worse product than eating a fraction of a cent. And a unique-key ledger makes replays harmless but not free; you still need the failed-metering retry path, or you've moved the leak instead of closing it.&lt;/p&gt;

&lt;p&gt;Both patterns ship in &lt;a href="https://duonglabs.com/a2" rel="noopener noreferrer"&gt;my Next.js SaaS Starter Kit&lt;/a&gt; ($149 one-time, my own product) alongside Auth.js and Stripe Billing Meters — but they're a hundred lines each and worth writing yourself if you'd rather.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>nextjs</category>
      <category>saas</category>
    </item>
    <item>
      <title>Regression-testing an agent whose output is never the same twice</title>
      <dc:creator>Cuong Duong</dc:creator>
      <pubDate>Sun, 06 Sep 2026 07:11:35 +0000</pubDate>
      <link>https://dev.to/cuong_duong_566f34ed73efc/regression-testing-an-agent-whose-output-is-never-the-same-twice-53jc</link>
      <guid>https://dev.to/cuong_duong_566f34ed73efc/regression-testing-an-agent-whose-output-is-never-the-same-twice-53jc</guid>
      <description>&lt;p&gt;You change one line of a prompt and have no idea what you broke, because &lt;code&gt;diff&lt;/code&gt; is useless on free text. The fix is to stop comparing text: freeze a set of real traces, assert on &lt;em&gt;behaviour&lt;/em&gt; — which tools got called, which constraints held, what the agent refused to do — and run that suite on every prompt or model change.&lt;/p&gt;

&lt;p&gt;Concretely, three files and a command. A &lt;code&gt;cases.jsonl&lt;/code&gt; of frozen inputs, an &lt;code&gt;assertions.ts&lt;/code&gt; that checks behaviour rather than strings, and a runner you invoke after each change. If you work in Claude Code or Cursor, this is a good candidate for a slash command, so the check costs you one line instead of a context switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The unit under test is not the final message. It's the trace: the ordered record of what the agent did to produce that message — tool calls and their arguments, retrieved documents, the number of turns, tokens spent, and the final output. A trace is structured data, and structured data can be asserted on.&lt;/p&gt;

&lt;p&gt;Four kinds of assertion cover most real failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sequence&lt;/strong&gt; — the agent called &lt;code&gt;search&lt;/code&gt; before &lt;code&gt;answer&lt;/code&gt;, and never called &lt;code&gt;delete_record&lt;/code&gt; at all. Cheap, deterministic, catches the scariest class of regression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constraint&lt;/strong&gt; — the answer cites at least one retrieved document; the JSON parses against the schema; the reply stays under the token budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refusal&lt;/strong&gt; — for inputs that should be declined, the agent declines. Prompt edits aimed at making an agent more helpful loosen refusals as a side effect, every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judged&lt;/strong&gt; — a second model scores the output against written criteria ("does it answer the question actually asked?"). Use it last and sparingly; it's the only assertion that can itself be wrong.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refund-past-window"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I want a refund, bought 400 days ago"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"assert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tools_called"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lookup_order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"check_policy"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tools_forbidden"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"issue_refund"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"must_mention"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"30 days"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"max_output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the suite twice per change: once before, once after. What you report is not pass/fail but &lt;em&gt;movement&lt;/em&gt; — three cases that used to call &lt;code&gt;check_policy&lt;/code&gt; no longer do. That's the sentence you want in front of you before a deploy, and it's the one a screenshot of a happy-path chat will never give you.&lt;/p&gt;

&lt;p&gt;Keep the case set small and adversarial. Twenty cases you chose because each one broke something once beats two hundred generated to look thorough — the suite has to run in under a minute or you will stop running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it pays, and where it doesn't
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Worth doing
&lt;/h3&gt;

&lt;p&gt;Prompts change weekly, models get swapped, the agent can take actions with consequences, or more than one person edits the prompt. Anywhere a silent behaviour change costs more than the hour this takes to set up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not worth it
&lt;/h3&gt;

&lt;p&gt;A single-turn wrapper with no tools and no side effects — assert the JSON schema and move on. Also skip it while you're still changing the agent's job description weekly: you'd be freezing traces of a thing that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest downsides.&lt;/strong&gt; Behavioural assertions encode your current idea of correct, so a deliberate improvement shows up as a failure and you rewrite cases — budget for that. Judged assertions add cost and their own flakiness, and a judge model that gets upgraded under you is itself an untested change. And a frozen trace set drifts away from real traffic; refresh it from production logs every so often, or it slowly starts testing last quarter's product.&lt;/p&gt;

&lt;p&gt;The alternative is not "no testing" — it's testing by user report, on a Tuesday, in public.&lt;/p&gt;

&lt;p&gt;I package this workflow — eval suites, trace review, regression checks, red-teaming — as slash commands and sub-agents for Claude Code and Cursor. It's my own product, $29 one-time: &lt;a href="https://duonglabs.com/a1" rel="noopener noreferrer"&gt;AI Agent QA &amp;amp; Eval Toolkit&lt;/a&gt;. The approach above works fine hand-rolled, too.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ai</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
