{"slug": "add-model-spend-behind-a-budget-row-not-a-browser-agent", "title": "Add Model Spend Behind a Budget Row, Not a Browser Agent", "summary": "A developer argues that model calls capable of mutating stored state should be gated by a tenant-scoped budget row rather than executed from a browser agent holding a provider key. The proposed reference implementation adds inference_budgets and inference_spend_events tables with a unique idempotency key, so handlers return HTTP 402 and write nothing when a locked budget row cannot cover a conservative token estimate. The author contends the failure point is not the model but the absence of a constraint at the layer that can refuse the write.", "body_md": "The support console fired a rewrite request at 4:11 p.m., and the widget treated a model response like a database grant. The browser held a provider key in local storage, posted a completion, then PATCHed the invoice with whatever paragraph came back. Did anyone ask whether that tenant still had inference budget for ticket rewrites this month? Did a second tab retry the same click and persist a second, slightly different version of the legal text?\n\nI think the fashionable answer this week is wrong, and I will not hedge it. An agent that never leaves the browser is a demo of latency, not a delivery path for customer writes. The first layer that fails is not the model, because the model did return usable prose. The missing budget row should have refused the write, and nobody on the path asked it.\n\nFree inference is a prototype subsidy, and it is not permission to mutate invoices, comments, or stored schema. If your UI can call a model and your API can persist the result, you already gave write authority to a meter you do not own. Why do we keep wiring it the other way around, week after week, in otherwise serious codebases? The happy path looks cheap until a retry doubles spend and the bad paragraph is already in Postgres.\n\nCan you roll that write back from a chat transcript after the toast has already gone green? I cannot, and I do not believe a streaming DOM node will save you either. The contract I want is boring on purpose, which is usually a sign it will survive contact with production. Every model call that can affect stored state must present a tenant, a purpose, and a budget row. If the row cannot cover the call, the handler returns 402 and nothing is written.\n\nWalk the request with me as a vertical slice, not as an architecture poster on a wiki. A human clicks rewrite, and the browser should post a command with `ticket_id`, `intent`, and an idempotency key. That client should not hold a provider key, and it should not pick a model name from a dropdown somebody added during a hack day. Where does this fail first when the spend envelope is not a real row?\n\nIt fails right after the model returns text, because nothing in the path is allowed to say no. Auth said the user could edit the ticket, and the model said here is a paragraph, so Postgres returned 200. The budget said nothing at all, because it was never asked as a locked row. That is not an artificial intelligence problem hiding in a vendor SDK. That is a missing constraint at the only layer that can refuse a write.\n\nI am labeling the next blocks as a reference implementation you can run locally, not as a souvenir from a named cluster. You should keep that label in your own repo until the 402 path is proven with a real status code. The unique constraint on the idempotency key is the whole point of the second table. Network retries must not mint a second spend event and a second invoice rewrite.\n\n```\nCREATE TABLE inference_budgets (\n  id            uuid PRIMARY KEY,\n  tenant_id     uuid NOT NULL,\n  purpose       text NOT NULL,\n  period_start  timestamptz NOT NULL,\n  period_end    timestamptz NOT NULL,\n  token_limit   integer NOT NULL,\n  token_spent   integer NOT NULL DEFAULT 0,\n  UNIQUE (tenant_id, purpose, period_start)\n);\n\nCREATE TABLE inference_spend_events (\n  id               uuid PRIMARY KEY,\n  budget_id        uuid NOT NULL REFERENCES inference_budgets(id),\n  idempotency_key  text NOT NULL UNIQUE,\n  provider         text NOT NULL,\n  input_tokens     integer NOT NULL,\n  output_tokens    integer NOT NULL,\n  status           text NOT NULL,\n  created_at       timestamptz NOT NULL DEFAULT now()\n);\n```\n\nHave you watched a mobile client replay a POST because the toast never rendered on a flaky radio? The second write is almost always the one that lands in support later. A FastAPI handler can refuse the business write when a locked budget row cannot cover a conservative estimate. This sketch estimates input tokens plus a hard output cap, then reconciles after the provider responds, which is the actual seam.\n\n```\n# Reference implementation: prove the 402 locally. Not production gospel.\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel, Field\n\nrouter = APIRouter()\nESTIMATED_OUTPUT_CAP = 800\n\nclass RewriteCommand(BaseModel):\n    ticket_id: str\n    intent: str = Field(pattern=\"^rewrite_friendly$\")\n    idempotency_key: str\n    tenant_id: str\n\n@router.post(\"/tickets/{ticket_id}/rewrite\")\ndef rewrite_ticket(\n    ticket_id: str,\n    cmd: RewriteCommand,\n    db=Depends(get_db),\n    models=Depends(get_model_client),\n):\n    if cmd.ticket_id != ticket_id:\n        raise HTTPException(409, \"ticket_id mismatch\")\n\n    existing = db.fetch_spend_event(cmd.idempotency_key)\n    if existing:\n        return {\"status\": existing.status, \"spend_id\": existing.id, \"replayed\": True}\n\n    budget = db.lock_budget(cmd.tenant_id, purpose=\"ticket_rewrite\")\n    if budget is None:\n        raise HTTPException(403, \"no budget row for purpose\")\n\n    estimate = estimate_input_tokens(ticket_id) + ESTIMATED_OUTPUT_CAP\n    if budget.token_spent + estimate > budget.token_limit:\n        raise HTTPException(402, \"inference budget exhausted\")\n\n    reservation_id = db.reserve_tokens(budget.id, estimate, cmd.idempotency_key)\n    try:\n        result = models.complete(\n            purpose=\"ticket_rewrite\",\n            prompt=load_ticket_prompt(ticket_id),\n            max_tokens=ESTIMATED_OUTPUT_CAP,\n        )\n    except ProviderError as exc:\n        db.release_reservation(reservation_id, reason=str(exc))\n        raise HTTPException(502, \"provider failed; budget released\") from exc\n\n    db.finalize_spend(\n        reservation_id,\n        provider=result.provider,\n        input_tokens=result.input_tokens,\n        output_tokens=result.output_tokens,\n    )\n    db.apply_ticket_rewrite(ticket_id, result.text, spend_id=reservation_id)\n    return {\"status\": \"applied\", \"spend_id\": reservation_id}\n```\n\nNotice what the handler refuses to do, even when the process is running on a scratch box you stood up for a demo. It does not accept a model name from the client, and it does not stream tokens into a websocket that also applies SQL. The purpose string is the product contract, not a log field you add later for analytics. Why would `playground_chat` and `ticket_rewrite` share one pool when they do not share one blast radius?\n\nDisclosure: This article was prepared as part of MonkeyCode's product outreach.\n\nA scratch environment is the right place to teach the ledger to say no, because that experiment should not ride a paid provider bill. MonkeyCode's free model access and free server option can host that slice while you are still proving the refusal path. Treat the open-source project as a sandbox for the contract, not as a substitute for the contract. If you cannot get a 402 from the free box, you will not get a real refusal in production either.\n\n```\npython -m venv .venv\nsource .venv/bin/activate\npip install fastapi uvicorn \"psycopg[binary]\"\nuvicorn app:app --reload --port 8080\n\npsql \"$DATABASE_URL\" -c \"insert into inference_budgets\n  (id, tenant_id, purpose, period_start, period_end, token_limit)\n  values ('00000000-0000-0000-0000-000000000001',\n          '11111111-1111-1111-1111-111111111111',\n          'ticket_rewrite', now(), now() + interval '30 days', 200);\"\n\ncurl -sS -X POST \"$API/tickets/T-42/rewrite\" \\\n  -H \"content-type: application/json\" \\\n  -H \"authorization: Bearer $DEV_TOKEN\" \\\n  -d '{\"ticket_id\":\"T-42\",\"intent\":\"rewrite_friendly\",\n       \"idempotency_key\":\"rewrite-T-42-01\",\n       \"tenant_id\":\"11111111-1111-1111-1111-111111111111\"}'\n```\n\nThe first call may apply if the estimate still fits inside that tiny envelope. The second call, with a new idempotency key, should return 402 and leave the ticket body untouched. Replaying the same key should return the original status and still not double-write the invoice. If your status codes do not split that way, the seam is theater, not a contract.\n\nI also want a test that the browser never saw a provider key in headers or payloads. That is the difference between a tab-resident agent and a feature that survives a stolen laptop. The test is ugly on purpose, because pretty tests tend to skip the overdraw and only assert that JSON looks friendly.\n\n``` python\ndef test_rewrite_does_not_overdraw_or_write(client, db):\n    db.set_token_limit(tenant=\"t1\", purpose=\"ticket_rewrite\", limit=50)\n    before = db.ticket_body(\"T-42\")\n    first = client.post(\"/tickets/T-42/rewrite\", json=command(\"T-42\", \"k1\"))\n    assert first.status_code in {200, 402}\n    second = client.post(\"/tickets/T-42/rewrite\", json=command(\"T-42\", \"k2\"))\n    assert second.status_code == 402\n    if first.status_code == 402:\n        assert db.ticket_body(\"T-42\") == before\n    assert \"sk-\" not in str(second.headers)\n    replay = client.post(\"/tickets/T-42/rewrite\", json=command(\"T-42\", \"k1\"))\n    assert replay.json().get(\"replayed\") in {True, None} or replay.status_code == 402\n```\n\nIs that test going to impress a design critique on a clean-code thread? No, and I do not need it to. Spend is a row, writes are a later row, and the model is a vendor behind a purpose. The browser is a client, not a wallet, and it should not get a vote on either row.\n\nThe failure mode looks like this, and it is more common than a clever browser agent demo. A handler temporarily reads `max_tokens` from the client, and the client sends a larger cap after the first 402. A streaming UI applies a partial sentence when the socket dies at eighty percent, then applies again on resume. A free-server compose file lands in production with the budget check commented out because it made the pitch deck jitter.\n\nThe model layer looks healthy, and the persist layer looks healthy, so the dashboard stays green. The handoff is an `if` statement somebody deleted on a Friday so the demo would not blink. Does that sound like a model-quality incident to you? It is a permissions incident with extra tokens attached.\n\nPerformance testing of the API does not save you if every scenario still has tokens left in the envelope. A load test that never exhausts budget is a load test of the happy path, which you already knew worked. Can you show me the 402 p95, and can you show that ticket bodies did not change on those requests? If you cannot, you have not tested the contract you claim to have.\n\nIf your product is a local playground with no persistence, a budget row is ceremony, and you should not add it for the aesthetic. If you cannot name a tenant, a purpose, and a period, you are not ready to attach a ledger. A free model will only hide that gap until the first shared demo key leaks into a write path. Also skip this sketch if you need hard multi-region accounting today, because it assumes one database and a row lock.\n\nThat starting contract is not a billing platform, and I will not pretend otherwise. People who only need a read-only eval slice should stay on the read path until a human grants write authority. Do not use this article as cover for giving a chat widget schema rights. The whole point is the opposite move: prove refusal before you celebrate the prose.\n\nProve the click, the 402, the replay, and the absent provider key before you ship the rewrite. Keep model names on the server, keep purposes distinct, and keep the free server in the eval path until the ledger refuses correctly. Which layer handoff is least stable in your stack right now, budget to model or model to persist? Send a failure state or a response code, not a model name, because I care whether the write happened after the budget already said no.", "url": "https://wpnews.pro/news/add-model-spend-behind-a-budget-row-not-a-browser-agent", "canonical_source": "https://dev.to/kongkong1/add-model-spend-behind-a-budget-row-not-a-browser-agent-3ah", "published_at": "2026-09-23 15:58:30+00:00", "updated_at": "2026-09-23 16:29:30.729219+00:00", "lang": "en", "topics": ["ai-infrastructure", "ai-agents", "mlops", "developer-tools", "ai-tools"], "entities": ["FastAPI", "Postgres"], "alternates": {"html": "https://wpnews.pro/news/add-model-spend-behind-a-budget-row-not-a-browser-agent", "markdown": "https://wpnews.pro/news/add-model-spend-behind-a-budget-row-not-a-browser-agent.md", "text": "https://wpnews.pro/news/add-model-spend-behind-a-budget-row-not-a-browser-agent.txt", "jsonld": "https://wpnews.pro/news/add-model-spend-behind-a-budget-row-not-a-browser-agent.jsonld"}}