[{"data":1,"prerenderedAt":777},["ShallowReactive",2],{"blog-llm-endpoint-abuse-denial-of-wallet-en":3},{"slug":4,"published":5,"minutes":6,"category":7,"tags":8,"keywords":14,"about":27,"sources":35,"cover":69,"og":70,"expertise":71,"locales":72,"lang":73,"title":76,"description":77,"coverAlt":78,"metaTitle":79,"takeaways":80,"faq":86,"toc":102,"blocks":138,"others":590},"llm-endpoint-abuse-denial-of-wallet","2026-10-16",14,"web",[9,10,11,12,13],"Nuxt","LLM security","Rate limiting","OWASP","Cost control",[15,16,17,18,19,20,21,22,23,24,25,26],"denial of wallet llm","owasp llm10 unbounded consumption","rate limit llm api nuxt","nuxt security rate limiter","protect openai api key frontend","llm token budget per user","limit llm cost per user","nitro api rate limiting","cloudflare turnstile nuxt","anthropic spend limit","openai hard spend limit","llm prompt size limit",[28,30,33],{"name":11,"url":29},"https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FRate_limiting",{"name":31,"url":32},"Denial-of-service attack","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FDenial-of-service_attack",{"name":9,"url":34},"https:\u002F\u002Fnuxt.com\u002F",[36,39,42,45,48,51,54,57,60,63,66],{"title":37,"url":38},"OWASP GenAI Security Project: LLM10:2025 Unbounded Consumption","https:\u002F\u002Fgenai.owasp.org\u002Fllmrisk\u002Fllm102025-unbounded-consumption\u002F",{"title":40,"url":41},"Nuxt Security: Rate Limiter middleware","https:\u002F\u002Fnuxt-security.vercel.app\u002Fmiddleware\u002Frate-limiter",{"title":43,"url":44},"Nuxt Security: Request Size Limiter middleware","https:\u002F\u002Fnuxt-security.vercel.app\u002Fmiddleware\u002Frequest-size-limiter",{"title":46,"url":47},"nuxt-auth-utils: README (GitHub)","https:\u002F\u002Fgithub.com\u002Fatinux\u002Fnuxt-auth-utils",{"title":49,"url":50},"h3: request utilities (getRequestIP, readValidatedBody)","https:\u002F\u002Fh3.dev\u002Futils\u002Frequest",{"title":52,"url":53},"Upstash: Ratelimiting algorithms","https:\u002F\u002Fupstash.com\u002Fdocs\u002Fredis\u002Fsdks\u002Fratelimit-ts\u002Falgorithms",{"title":55,"url":56},"Cloudflare Turnstile: Validate the token","https:\u002F\u002Fdevelopers.cloudflare.com\u002Fturnstile\u002Fget-started\u002Fserver-side-validation\u002F",{"title":58,"url":59},"Claude Platform Docs: Rate limits and spend limits","https:\u002F\u002Fplatform.claude.com\u002Fdocs\u002Fen\u002Fapi\u002Frate-limits",{"title":61,"url":62},"Claude Platform Docs: Token counting","https:\u002F\u002Fplatform.claude.com\u002Fdocs\u002Fen\u002Fbuild-with-claude\u002Ftoken-counting",{"title":64,"url":65},"OpenAI API Docs: Spend limits","https:\u002F\u002Fdevelopers.openai.com\u002Fapi\u002Fdocs\u002Fguides\u002Fspend-limits",{"title":67,"url":68},"OpenAI API Docs: Rate limits","https:\u002F\u002Fplatform.openai.com\u002Fdocs\u002Fguides\u002Frate-limits","\u002Fimages\u002Fblog\u002Fllm-endpoint-abuse-denial-of-wallet\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fllm-endpoint-abuse-denial-of-wallet\u002Fog.jpg","vue-nuxt-developer",[73,74,75],"en","de","hu","Denial of wallet: protecting public LLM endpoints in a Nuxt app","How to stop abuse and runaway cost on a public LLM route in Nuxt: auth, per-user rate limits, prompt caps, token budgets, Turnstile and spend limits.","Requests to a Nuxt chat endpoint pass through bot checks, sessions, rate limits, prompt caps and a token budget before reaching the model API.","Denial of wallet: securing Nuxt LLM routes · Balázs Csorba",[81,82,83,84,85],"A public LLM route is a pay-per-use API with your card behind it; OWASP files the risk under LLM10:2025 Unbounded Consumption.","Put a session in front of the model call and never expose a generic proxy: the server picks the model, the system prompt and max_tokens.","Limit requests per user and per IP in a shared store, and limit tokens separately, because one request can cost a thousand times another.","Reserve a token budget before the call and settle it with the real usage afterwards, using an atomic counter.","Provider spend limits are the last fuse, not the control: they switch the feature off for every user at once and are not enforced instantly.",[87,90,93,96,99],{"q":88,"a":89},"What is denial of wallet?","Denial of wallet is an attack on a pay-per-use service where the goal is cost rather than downtime. OWASP lists it as an example of LLM10:2025 Unbounded Consumption: an attacker generates so many operations that the bill for the provider becomes unsustainable.",{"q":91,"a":92},"Is the nuxt-security rate limiter enough for an LLM endpoint?","It is a good first layer, but its own documentation says the built-in rate limiter is not suitable for complex production applications. It counts requests, not tokens, and its default LRU cache is per server instance. Pair it with an edge limit and a per-user token budget in a shared store such as Redis.",{"q":94,"a":95},"Should I rate-limit by IP or by user?","Both. IP limits protect the routes that work before login and blunt simple floods. Per-user limits are the ones that matter for cost, because many legitimate users can share an IP and one attacker can rotate through many.",{"q":97,"a":98},"How do I limit the length of prompts?","Cap the request body size, validate the message length with a schema, keep the conversation history on the server instead of accepting it from the client, and check the token count before calling the model. Set max_tokens on the server to cap the output.",{"q":100,"a":101},"Do provider spend limits protect me from abuse?","Only as a last resort. OpenAI hard limits and Anthropic spend limits stop all traffic on the organization, project or workspace once reached, so an attacker who hits them takes the feature down for everyone. OpenAI also notes that enforcement is not instantaneous and spend can slightly exceed the limit.",[103,105,108,111,114,117,120,123,126,129,132,135],{"id":104,"title":88},"what-is-denial-of-wallet",{"id":106,"title":107},"abuse-patterns","Which abuse patterns hit public LLM routes?",{"id":109,"title":110},"layered-defence","What does a layered defence look like?",{"id":112,"title":113},"authentication-first","Why does authentication come first?",{"id":115,"title":116},"rate-limits","How should you rate-limit per user and per IP?",{"id":118,"title":119},"input-limits","How do you cap prompt size and output?",{"id":121,"title":122},"token-budgets","How do you enforce token budgets per user?",{"id":124,"title":125},"bot-protection","Where do CAPTCHAs and Turnstile fit?",{"id":127,"title":128},"provider-spend-limits","What do provider spend limits catch?",{"id":130,"title":131},"monitoring","What should you monitor and alert on?",{"id":133,"title":134},"checklist","A checklist for a public LLM route",{"id":136,"title":137},"sources","Sources",[139,143,146,149,157,160,161,164,209,212,213,216,225,226,238,251,252,268,287,289,300,330,333,334,345,352,361,363,364,370,376,391,393,408,409,419,421,428,429,432,484,487,488,491,515,522,523,547,554,555],{"type":140,"content":141},"paragraph",[142],"An LLM feature on a public website is a pay-per-use API with your payment details behind it. A contact assistant, a product finder or a \"summarize this page\" button all end in a server route that forwards text to a model and pays per token. If that route answers anyone, any number of times, with any input, the question is not whether someone will use it as a free model API, only when.",{"type":140,"content":144},[145],"This article is the cost and abuse side of shipping LLM features in Nuxt. It maps the OWASP LLM10:2025 Unbounded Consumption risk to concrete layers in a Nuxt and Nitro app: sessions, request and token limits, prompt-size caps, per-user budgets, Cloudflare Turnstile and the spend limits that OpenAI and Anthropic offer, with code you can adapt.",{"type":147,"level":148,"id":104,"text":88},"heading",2,{"type":140,"content":150},[151,152,156],"OWASP describes ",{"tag":153,"href":38,"children":154},"a",[155],"Unbounded Consumption"," as an LLM application allowing users to run \"excessive and uncontrolled inferences\", which leads to denial of service, economic losses, model theft and degraded service. Denial of wallet is the cost-focused variant: an attacker generates excessive operations to exploit the pay-per-use model of cloud AI services, causing unsustainable costs for the provider. Nothing has to go down for the attack to succeed; the bill is the damage.",{"type":140,"content":158},[159],"LLM routes are more exposed than ordinary API routes for two reasons. Cost per request is variable and large: a request that sends a long conversation and asks for a long answer can cost orders of magnitude more than a one-line question. And the output is directly useful to the attacker, so an open chat endpoint is worth scripting even when it has nothing to do with your product.",{"type":147,"level":148,"id":106,"text":107},{"type":140,"content":162},[163],"The OWASP entry lists seven examples. Mapped onto a typical Nuxt feature, they reduce to a handful of patterns, each with a primary control:",{"type":165,"head":166,"rows":173},"table",[167,169,171],[168],"Pattern",[170],"What it looks like",[172],"Primary control",[174,181,188,195,202],[175,177,179],[176],"Free-riding proxy (denial of wallet)",[178],"Scripts use your route as a free model API; prompts unrelated to your product",[180],"Session, fixed system prompt, per-user token budget",[182,184,186],[183],"Repeated requests",[185],"High request rate from a few accounts or IPs",[187],"Rate limits at the edge and per user",[189,191,193],[190],"Variable-length input flood, continuous input overflow",[192],"Huge bodies, pasted documents, ever-growing histories",[194],"Body size limit, schema validation, token count cap",[196,198,200],[197],"Resource-intensive queries",[199],"Requests for very long outputs, tool loops, heavy reasoning",[201],"Server-side max_tokens, step limits, timeouts",[203,205,207],[204],"Model extraction and functional replication",[206],"Systematic queries that harvest outputs as training data",[208],"Volume caps, no logprobs, anomaly monitoring",{"type":140,"content":210},[211],"There is a sixth pattern OWASP does not name but every team meets: self-inflicted consumption. A retry loop without a cap, an agent that keeps calling tools, or a client bug that resends the full history on every keystroke burns budget exactly like an attacker. The same limits catch it.",{"type":147,"level":148,"id":109,"text":110},{"type":140,"content":214},[215],"No single control is enough, because each one measures something different. A rate limiter counts requests, a size limit counts bytes, a budget counts tokens and a spend limit counts money. I order them from cheapest to most expensive to evaluate, so that rejected traffic costs as little as possible:",{"type":217,"attrs":218,"inner":222,"caption":223},"diagram",{"viewBox":219,"role":220,"aria-labelledby":221},"0 0 720 170","img","dow1-t dow1-d","\u003Ctitle id=\"dow1-t\">Layers in front of an LLM call\u003C\u002Ftitle>\u003Cdesc id=\"dow1-d\">A request passes the edge and bot check, then the session check, then the request rate limit, then input caps on size and tokens, then the per-user token budget, and only then reaches the model API, where the provider spend limit is the final fuse.\u003C\u002Fdesc>\u003Cdefs>\u003Cmarker id=\"dow1-ah\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\u003Cpath d=\"M0 0L10 5L0 10z\" class=\"d-head\" \u002F>\u003C\u002Fmarker>\u003C\u002Fdefs>\u003Crect x=\"20\" y=\"47\" width=\"95\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"68\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Edge, bots\u003C\u002Ftext>\u003Ctext x=\"68\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">WAF, Turnstile\u003C\u002Ftext>\u003Cpath d=\"M115 85H135\" class=\"d-line\" marker-end=\"url(#dow1-ah)\" \u002F>\u003Crect x=\"137\" y=\"47\" width=\"95\" height=\"76\" rx=\"10\" class=\"d-sky\" \u002F>\u003Ctext x=\"184\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Session\u003C\u002Ftext>\u003Ctext x=\"184\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">401 if none\u003C\u002Ftext>\u003Cpath d=\"M232 85H252\" class=\"d-line\" marker-end=\"url(#dow1-ah)\" \u002F>\u003Crect x=\"254\" y=\"47\" width=\"95\" height=\"76\" rx=\"10\" class=\"d-accent\" \u002F>\u003Ctext x=\"302\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Rate limit\u003C\u002Ftext>\u003Ctext x=\"302\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">requests\u003C\u002Ftext>\u003Cpath d=\"M349 85H369\" class=\"d-line\" marker-end=\"url(#dow1-ah)\" \u002F>\u003Crect x=\"371\" y=\"47\" width=\"95\" height=\"76\" rx=\"10\" class=\"d-mint\" \u002F>\u003Ctext x=\"418\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Input caps\u003C\u002Ftext>\u003Ctext x=\"418\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">bytes, tokens\u003C\u002Ftext>\u003Cpath d=\"M466 85H486\" class=\"d-line\" marker-end=\"url(#dow1-ah)\" \u002F>\u003Crect x=\"488\" y=\"47\" width=\"95\" height=\"76\" rx=\"10\" class=\"d-gold\" \u002F>\u003Ctext x=\"536\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Budget\u003C\u002Ftext>\u003Ctext x=\"536\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">tokens per day\u003C\u002Ftext>\u003Cpath d=\"M583 85H603\" class=\"d-line\" marker-end=\"url(#dow1-ah)\" \u002F>\u003Crect x=\"605\" y=\"47\" width=\"95\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"652\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Model API\u003C\u002Ftext>\u003Ctext x=\"652\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">spend limit\u003C\u002Ftext>",[224],"Cheap checks first: a request rejected at the edge costs nothing, one rejected by the provider spend limit has already taken the feature down.",{"type":147,"level":148,"id":112,"text":113},{"type":140,"content":227},[228,229,232,233,237],"Every other limit needs an identity to attach to. Without a session, the only key you have is the IP address, and that is weak. With ",{"tag":153,"href":47,"children":230},[231],"nuxt-auth-utils",", ",{"tag":234,"children":235},"code",[236],"requireUserSession(event)"," returns the session or responds with 401, so the first line of the handler decides whether the model call can happen at all. For a feature that must work before login, such as a product finder, issue a lightweight anonymous session after a bot check and give it a much smaller budget than a logged-in customer.",{"type":140,"content":239},[240,241,244,245,250],"The second rule is to never ship a generic proxy. If the client can choose the model, the system prompt, ",{"tag":234,"children":242},[243],"max_tokens"," or tools, your endpoint is an open model API with extra steps. The client sends a message and maybe a conversation id; the server decides everything else. That also keeps the API key on the server, as described in ",{"tag":246,"to":247,"children":248},"link","\u002Fblog\u002Fnuxt-llm-features-ai-sdk-streaming",[249],"shipping LLM features in Nuxt",".",{"type":147,"level":148,"id":115,"text":116},{"type":140,"content":253},[254,255,258,259,262,263,267],"The ",{"tag":153,"href":41,"children":256},[257],"nuxt-security"," module ships a rate limiter that is enabled globally by default: 150 requests per 300,000 ms per IP, stored in an LRU cache, answering 429 Too Many Requests when exceeded. It can be tuned per route through ",{"tag":234,"children":260},[261],"routeRules",", and its storage can be any ",{"tag":153,"href":264,"children":265},"https:\u002F\u002Funstorage.unjs.io\u002F",[266],"unstorage"," driver. Its own documentation is honest about the limits: the built-in rate limiter \"is not suitable for complex production applications\", and it recommends solutions on the infrastructure layer.",{"type":140,"content":269},[270,271,274,275,278,279,282,283,286],"For an LLM route that means three adjustments. Use a shared store, because an in-memory LRU cache is per instance and resets on every deploy. Set a much tighter limit on the model route than on the rest of the site. And resolve the client IP correctly: h3's ",{"tag":234,"children":272},[273],"getRequestIP"," only reads ",{"tag":234,"children":276},[277],"X-Forwarded-For"," when you opt in, because the leftmost entry is client input; behind Cloudflare, nuxt-security's ",{"tag":234,"children":280},[281],"ipHeader"," option can point at ",{"tag":234,"children":284},[285],"cf-connecting-ip"," instead.",{"type":234,"code":288},"\u002F\u002F nuxt.config.ts (illustrative)\nexport default defineNuxtConfig({\n  modules: ['nuxt-security', 'nuxt-auth-utils'],\n  security: {\n    rateLimiter: {\n      driver: { name: 'redis', options: { url: process.env.REDIS_URL } },\n      ipHeader: 'cf-connecting-ip', \u002F\u002F only if Cloudflare sits in front\n    },\n  },\n  routeRules: {\n    '\u002Fapi\u002Fchat': {\n      security: {\n        rateLimiter: { tokensPerInterval: 20, interval: 60_000 },\n        requestSizeLimiter: { maxRequestSizeInBytes: 32_000 },\n      },\n    },\n  },\n})",{"type":140,"content":290},[291,292,295,296,299],"This limiter is keyed by IP. The per-user limit belongs in the handler, after ",{"tag":234,"children":293},[294],"requireUserSession",", keyed by the user id. Which algorithm you use there matters less than using one consistently; the trade-offs as ",{"tag":153,"href":53,"children":297},[298],"Upstash documents them",":",{"type":165,"head":301,"rows":308},[302,304,306],[303],"Algorithm",[305],"Strength",[307],"Weakness",[309,316,323],[310,312,314],[311],"Fixed window",[313],"Very cheap in storage and computation",[315],"Bursts leak through at window boundaries; stampedes when a new window starts",[317,319,321],[318],"Sliding window",[320],"Fixes the boundary problem",[322],"More storage and computation; an approximation",[324,326,328],[325],"Token bucket",[327],"Smooths bursts; allows a higher initial burst",[329],"More state to reason about",{"type":140,"content":331},[332],"Anthropic's own API uses a token bucket, continuously replenished up to the limit rather than reset at fixed intervals, which is a reasonable default to copy for a chat feature where users type in bursts.",{"type":147,"level":148,"id":118,"text":119},{"type":140,"content":335},[336,337,340,341,344],"A request limit says nothing about what each request costs. Cap the input in three steps. First bytes: nuxt-security's request size limiter answers 413 Payload Too Large above ",{"tag":234,"children":338},[339],"maxRequestSizeInBytes",", which defaults to 2,000,000; a chat message needs a few kilobytes. Then shape: validate the body with ",{"tag":234,"children":342},[343],"readValidatedBody"," and a schema that limits message length. Then tokens: count before you call.",{"type":140,"content":346},[347,348,351],"The biggest hole is usually conversation history. If the client sends the whole conversation on every turn, an attacker can send a fabricated history of any length. Keep the history on the server, keyed by conversation id, and trim it to a fixed number of turns or tokens. Anthropic's ",{"tag":153,"href":62,"children":349},[350],"token counting endpoint"," is free, has its own rate limit separate from message creation, and returns an estimate of input tokens, which is enough for a cap.",{"type":140,"content":353},[354,355,357,358,360],"Output is capped by ",{"tag":234,"children":356},[243],", set on the server. Note what it does and does not do: for Anthropic's rate limits, output tokens are counted as they are produced and ",{"tag":234,"children":359},[243]," does not factor into the output-tokens-per-minute calculation. It is a cost ceiling per request, not a rate control, so you still need the budget in the next section.",{"type":234,"code":362},"\u002F\u002F server\u002Fapi\u002Fchat.post.ts (illustrative)\nimport { z } from 'zod'\n\nconst Body = z.object({\n  conversationId: z.string().uuid(),\n  message: z.string().min(1).max(4000),\n})\nconst MAX_INPUT = 12_000   \u002F\u002F tokens, after history trimming\nconst MAX_OUTPUT = 800     \u002F\u002F the server decides, never the client\n\nexport default defineEventHandler(async (event) => {\n  const { user } = await requireUserSession(event)          \u002F\u002F 401 without a session\n  const body = await readValidatedBody(event, Body.parse)    \u002F\u002F 400 on bad or long input\n\n  const history = await loadHistory(user.id, body.conversationId, { maxTurns: 10 })\n  const messages = [...history, { role: 'user', content: body.message }]\n\n  const { input_tokens } = await anthropic.messages.countTokens({ model: MODEL, system: SYSTEM, messages })\n  if (input_tokens > MAX_INPUT) throw createError({ statusCode: 413, statusMessage: 'Conversation too long' })\n\n  const reserved = input_tokens + MAX_OUTPUT\n  const key = await reserveTokens(user.id, reserved)          \u002F\u002F null when the daily budget is spent\n  if (!key) throw createError({ statusCode: 429, statusMessage: 'Daily limit reached' })\n\n  let used = 0\n  try {\n    const res = await anthropic.messages.create({ model: MODEL, system: SYSTEM, messages, max_tokens: MAX_OUTPUT })\n    const u = res.usage\n    used = u.input_tokens + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + u.output_tokens\n    await saveTurn(user.id, body.conversationId, body.message, res.content)\n    return res.content\n  } finally {\n    await settleTokens(key, reserved, used)                   \u002F\u002F refund the unused part\n  }\n})",{"type":147,"level":148,"id":121,"text":122},{"type":140,"content":365},[366,367,369],"A budget is the control that actually bounds cost: tokens per user per day, or per anonymous session per hour. The pattern I use is reserve, then settle. Before the call, reserve the worst case, which is the counted input plus ",{"tag":234,"children":368},[243],". If the reservation would exceed the budget, reject with 429. After the call, settle with the real usage from the response and refund the difference. Concurrent requests from the same user then cannot overspend, because each one has already claimed its share.",{"type":217,"attrs":371,"inner":373,"caption":374},{"viewBox":219,"role":220,"aria-labelledby":372},"dow2-t dow2-d","\u003Ctitle id=\"dow2-t\">Reserve and settle a token budget\u003C\u002Ftitle>\u003Cdesc id=\"dow2-d\">The handler counts input tokens, reserves input plus max_tokens against the daily budget with an atomic increment, rejects if over budget, calls the model, and then refunds the difference between the reservation and the actual usage.\u003C\u002Fdesc>\u003Cdefs>\u003Cmarker id=\"dow2-ah\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\u003Cpath d=\"M0 0L10 5L0 10z\" class=\"d-head\" \u002F>\u003C\u002Fmarker>\u003C\u002Fdefs>\u003Crect x=\"20\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-box\" \u002F>\u003Ctext x=\"79\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Count\u003C\u002Ftext>\u003Ctext x=\"79\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">input tokens\u003C\u002Ftext>\u003Cpath d=\"M138 85H158\" class=\"d-line\" marker-end=\"url(#dow2-ah)\" \u002F>\u003Crect x=\"160\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-accent\" \u002F>\u003Ctext x=\"220\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Reserve\u003C\u002Ftext>\u003Ctext x=\"220\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">+ max_tokens\u003C\u002Ftext>\u003Cpath d=\"M279 85H299\" class=\"d-line\" marker-end=\"url(#dow2-ah)\" \u002F>\u003Crect x=\"301\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-gold\" \u002F>\u003Ctext x=\"360\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Over budget?\u003C\u002Ftext>\u003Ctext x=\"360\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">429, no call\u003C\u002Ftext>\u003Cpath d=\"M419 85H439\" class=\"d-line\" marker-end=\"url(#dow2-ah)\" \u002F>\u003Crect x=\"441\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-sky\" \u002F>\u003Ctext x=\"500\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Call model\u003C\u002Ftext>\u003Ctext x=\"500\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">usage in reply\u003C\u002Ftext>\u003Cpath d=\"M560 85H580\" class=\"d-line\" marker-end=\"url(#dow2-ah)\" \u002F>\u003Crect x=\"582\" y=\"47\" width=\"118\" height=\"76\" rx=\"10\" class=\"d-mint\" \u002F>\u003Ctext x=\"641\" y=\"81\" text-anchor=\"middle\" class=\"d-text\">Settle\u003C\u002Ftext>\u003Ctext x=\"641\" y=\"103\" text-anchor=\"middle\" class=\"d-small\">refund unused\u003C\u002Ftext>",[375],"The reservation makes the budget safe under concurrency; settling keeps it accurate.",{"type":140,"content":377},[378,379,382,383,386,387,390],"The counter must be atomic. A ",{"tag":234,"children":380},[381],"getItem"," followed by ",{"tag":234,"children":384},[385],"setItem"," through Nitro storage is a read-modify-write, and two parallel requests can both read the old value. Redis ",{"tag":234,"children":388},[389],"INCRBY"," returns the new total in one step, so the check and the reservation cannot race:",{"type":234,"code":392},"\u002F\u002F server\u002Futils\u002Fbudget.ts (illustrative, ioredis)\nconst DAILY_TOKENS = 200_000\n\nexport async function reserveTokens(userId: string, n: number) {\n  const key = `llm:budget:${userId}:${new Date().toISOString().slice(0, 10)}`\n  const total = await redis.incrby(key, n)\n  if (total === n) await redis.expire(key, 60 * 60 * 48)\n  if (total > DAILY_TOKENS) { await redis.decrby(key, n); return null }\n  return key\n}\n\nexport async function settleTokens(key: string, reserved: number, used: number) {\n  if (reserved > used) await redis.decrby(key, reserved - used)\n}",{"type":140,"content":394},[395,396,399,400,403,404,250],"Count all input tokens when you settle. With prompt caching, Anthropic's ",{"tag":234,"children":397},[398],"input_tokens"," only covers tokens after the last cache breakpoint; the total input is ",{"tag":234,"children":401},[402],"cache_read_input_tokens + cache_creation_input_tokens + input_tokens",". Whether you weigh cached tokens at their lower price is a product decision; a plain token budget is easier to explain to users. For the cost side of caching and routing, see ",{"tag":246,"to":405,"children":406},"\u002Fblog\u002Fllm-cost-latency-prompt-caching-routing",[407],"prompt caching and model routing",{"type":147,"level":148,"id":124,"text":125},{"type":140,"content":410},[411,412,415,416,250],"A bot check raises the price of automation; it does not cap cost. I use it at the point where an identity is created, for example before issuing an anonymous chat session, not on every message. With ",{"tag":153,"href":56,"children":413},[414],"Cloudflare Turnstile",", server-side validation through the Siteverify API is mandatory, because the client widget alone protects nothing: tokens can be forged, each token is valid for 300 seconds, and each can be validated only once, with a replay rejected as ",{"tag":234,"children":417},[418],"timeout-or-duplicate",{"type":234,"code":420},"\u002F\u002F server\u002Fapi\u002Fsession\u002Fanonymous.post.ts (illustrative)\nexport default defineEventHandler(async (event) => {\n  const { token } = await readBody\u003C{ token: string }>(event)\n  const r = await $fetch\u003C{ success: boolean }>('https:\u002F\u002Fchallenges.cloudflare.com\u002Fturnstile\u002Fv0\u002Fsiteverify', {\n    method: 'POST',\n    body: { secret: useRuntimeConfig().turnstileSecret, response: token, remoteip: getRequestIP(event) },\n  })\n  if (!r.success) throw createError({ statusCode: 403 })\n  await setUserSession(event, { user: { id: crypto.randomUUID(), tier: 'anonymous' } })\n  return { ok: true }\n})",{"type":422,"variant":423,"title":424,"body":425},"callout","warn","A bot check is not a budget",[426],[427],"Humans can be paid to solve challenges, and a valid session can still send expensive requests all day. Treat Turnstile as a speed bump in front of session creation, and let the per-session token budget do the capping.",{"type":147,"level":148,"id":127,"text":128},{"type":140,"content":430},[431],"Both large providers let you cap monthly spend, and you should set a cap below the amount that would hurt. But read the behaviour: these limits stop all traffic in their scope, for every user, until the month resets or you raise them.",{"type":165,"head":433,"rows":440},[434,436,438],[435],"Aspect",[437],"Claude API (Anthropic)",[439],"OpenAI API",[441,448,466,477],[442,444,446],[443],"Limit you set",[445],"Spend limit per organization, below the tier cap; custom spend and rate limits per workspace",[447],"Hard spend limit per organization or per project; spend alerts that only notify",[449,451,457],[450],"Response when reached",[452,453,456],"HTTP 400 ",{"tag":234,"children":454},[455],"invalid_request_error",", \"You have reached your specified API usage limits\"",[458,459,462,463],"HTTP 429 with ",{"tag":234,"children":460},[461],"organization_spend_limit_exceeded"," or ",{"tag":234,"children":464},[465],"project_spend_limit_exceeded",[467,469,475],[468],"Built-in ceiling",[470,471,474],"Monthly tier cap: Start 500, Build 1,000, Scale 200,000 USD; 429 ",{"tag":234,"children":472},[473],"enforced_spend_limit_reached"," without retry-after",[476],"Monthly usage limits per usage tier",[478,480,482],[479],"Caveat",[481],"Workspace limits cannot be set on the default workspace",[483],"\"Enforcement is not instantaneous\"; recorded spend can slightly exceed the limit",{"type":140,"content":485},[486],"Two practical consequences. Give each public feature its own Anthropic workspace or OpenAI project with its own key and limit, so a runaway chatbot cannot stop your internal tools. And handle the spend-limit responses explicitly: the SDKs retry 429s automatically, and Anthropic notes that retries fail until access resumes. Map them to a friendly \"temporarily unavailable\" state and page someone.",{"type":147,"level":148,"id":130,"text":131},{"type":140,"content":489},[490],"OWASP's mitigations include comprehensive logging with anomaly detection and graceful degradation. In a Nuxt app that translates into a few numbers per feature, logged with the user id and the conversation id:",{"type":492,"items":493},"list",[494,500,505,510],[495,499],{"tag":496,"children":497},"strong",[498],"Tokens per user per day",", with an alert when the top user is far above the median. Free-riders show up here first.",[501,504],{"tag":496,"children":502},[503],"Rejections by layer",": 401, 413 and 429 counts per hour. A sudden rise in 429s from the budget means someone is probing.",[506,509],{"tag":496,"children":507},[508],"Input tokens per request",", as a distribution, not an average. A long tail means the history or size caps are too loose.",[511,514],{"tag":496,"children":512},[513],"Daily cost per feature against a baseline",", with a provider spend alert set well below the hard limit.",{"type":140,"content":516},[517,518,250],"Degrade before you shut down. When spend crosses a threshold, switch anonymous users off first, then move logged-in users to a smaller model, and keep the feature alive for paying customers. That is a far better failure mode than the provider limit switching everything off at once. For tracing the calls behind these numbers, see ",{"tag":246,"to":519,"children":520},"\u002Fblog\u002Fagent-observability-opentelemetry",[521],"observability for LLM agents",{"type":147,"level":148,"id":133,"text":134},{"type":492,"ordered":524,"items":525},true,[526,528,530,535,537,539,541,543,545],[527],"The API key lives only in server runtime config; the client never sees it.",[529],"The route requires a session; anonymous sessions are issued only after a server-validated bot check.",[531,532,534],"Model, system prompt, tools and ",{"tag":234,"children":533},[243]," are fixed on the server.",[536],"Request rate limits per IP at the edge and per user in the handler, in a shared store.",[538],"Body size limit, schema validation, server-side history and a token count cap before the call.",[540],"A daily token budget per user with atomic reserve and settle.",[542],"Bounded retries, tool steps and timeouts, so your own code cannot loop.",[544],"A separate provider workspace or project per feature, with a spend alert and a hard limit.",[546],"Alerts on per-user token outliers, rejection spikes and daily cost, plus a degradation plan.",{"type":140,"content":548},[549,550,250],"Cost abuse is one half of the risk; the other is what the model does with hostile input. For that, see ",{"tag":246,"to":551,"children":552},"\u002Fblog\u002Fprompt-injection-lethal-trifecta-patterns",[553],"prompt injection defense",{"type":147,"level":148,"id":136,"text":137},{"type":492,"ordered":524,"items":556},[557,560,563,566,569,572,575,578,581,584,587],[558],{"tag":153,"href":38,"children":559},[37],[561],{"tag":153,"href":41,"children":562},[40],[564],{"tag":153,"href":44,"children":565},[43],[567],{"tag":153,"href":47,"children":568},[46],[570],{"tag":153,"href":50,"children":571},[49],[573],{"tag":153,"href":53,"children":574},[52],[576],{"tag":153,"href":56,"children":577},[55],[579],{"tag":153,"href":59,"children":580},[58],[582],{"tag":153,"href":62,"children":583},[61],[585],{"tag":153,"href":65,"children":586},[64],[588],{"tag":153,"href":68,"children":589},[67],[591,640,692,731],{"slug":592,"published":5,"minutes":593,"category":7,"tags":594,"keywords":599,"about":610,"sources":618,"cover":634,"og":635,"expertise":71,"locales":636,"lang":73,"title":637,"description":638,"coverAlt":639},"nuxt-i18n-multilingual-seo-hreflang",8,[9,595,596,597,598],"i18n","SEO","hreflang","Multilingual",[600,601,602,603,604,605,606,607,608,609],"nuxt i18n seo","nuxt hreflang","useLocaleHead","nuxt i18n canonical","hreflang x-default","prefix_except_default","multilingual seo nuxt","nuxt i18n sitemap hreflang","hreflang self-referencing","nuxt i18n baseUrl",[611,612,615],{"name":9,"url":34},{"name":613,"url":614},"Hreflang","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FHreflang",{"name":616,"url":617},"Internationalization and localization","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FInternationalization_and_localization",[619,622,625,628,631],{"title":620,"url":621},"Nuxt i18n docs: SEO (useLocaleHead)","https:\u002F\u002Fi18n.nuxtjs.org\u002Fdocs\u002Fguide\u002Fseo",{"title":623,"url":624},"Nuxt i18n docs: Routing strategies","https:\u002F\u002Fi18n.nuxtjs.org\u002Fdocs\u002Fguide",{"title":626,"url":627},"Google Search Central: Tell Google about localized versions of your page","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fspecialty\u002Finternational\u002Flocalized-versions",{"title":629,"url":630},"Google Search Central: Managing multi-regional and multilingual sites","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fspecialty\u002Finternational\u002Fmanaging-multi-regional-sites",{"title":632,"url":633},"Google Search Central: How to specify a canonical URL","https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fcrawling-indexing\u002Fconsolidate-duplicate-urls","\u002Fimages\u002Fblog\u002Fnuxt-i18n-multilingual-seo-hreflang\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fnuxt-i18n-multilingual-seo-hreflang\u002Fog.jpg",[73,74,75],"Multilingual SEO with Nuxt i18n: URL strategy, hreflang, canonicals and sitemaps","How to set up Nuxt i18n so search engines index every language: routing strategy, useLocaleHead, hreflang rules, partial translations and sitemaps.","Three language versions of one page, English, German and Hungarian, linked to each other with hreflang arrows and an x-default fallback.",{"slug":641,"published":642,"minutes":643,"category":7,"tags":644,"keywords":648,"about":656,"sources":663,"cover":685,"og":686,"expertise":687,"locales":688,"lang":73,"title":689,"description":690,"coverAlt":691},"llms-txt-vs-markdown-content-negotiation","2026-10-07",9,[645,646,647,9],"llms.txt","Content negotiation","AI agents",[645,649,650,651,652,653,654,655],"markdown for agents","content negotiation","AI crawlers","generative engine optimization","Accept text\u002Fmarkdown header","llms-full.txt","agent-ready website",[657,660],{"name":658,"url":659},"Markdown","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FMarkdown",{"name":661,"url":662},"Hypertext Transfer Protocol","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FHypertext_Transfer_Protocol",[664,667,670,673,676,679,682],{"title":665,"url":666},"llmstxt.org: The \u002Fllms.txt file (proposal, version 2)","https:\u002F\u002Fllmstxt.org\u002F",{"title":668,"url":669},"llmstxt.org: Changes from v1 to v2","https:\u002F\u002Fllmstxt.org\u002Fchanges.html",{"title":671,"url":672},"Cloudflare changelog: Markdown for Agents (12 February 2026)","https:\u002F\u002Fdevelopers.cloudflare.com\u002Fchangelog\u002Fpost\u002F2026-02-12-markdown-for-agents\u002F",{"title":674,"url":675},"Cloudflare docs: Markdown for Agents","https:\u002F\u002Fdevelopers.cloudflare.com\u002Ffundamentals\u002Freference\u002Fmarkdown-for-agents\u002F",{"title":677,"url":678},"Ahrefs: 137K sites analyzed, 97% of llms.txt files never get read (June 2026)","https:\u002F\u002Fahrefs.com\u002Fblog\u002Fllmstxt-study\u002F",{"title":680,"url":681},"Checkly: The current state of content negotiation for AI agents (February 2026)","https:\u002F\u002Fwww.checklyhq.com\u002Fblog\u002Fstate-of-ai-agent-content-negotation\u002F",{"title":683,"url":684},"Suganthan: tracking Cloudflare Markdown for Agents on one site","https:\u002F\u002Fsuganthan.com\u002Fblog\u002Fcloudflare-markdown-for-agents\u002F","\u002Fimages\u002Fblog\u002Fllms-txt-vs-markdown-content-negotiation\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fllms-txt-vs-markdown-content-negotiation\u002Fog.jpg","ai-engineer",[73,74,75],"llms.txt vs Markdown content negotiation: what agents actually fetch","llms.txt is a proposal, Markdown content negotiation is a header. What AI agents fetch, what the logs show, and how to serve both from Nuxt and nginx.","Four stacked layers that serve AI agents: declared WebMCP tools, a Markdown copy of every page, a negotiated Markdown response, and an llms.txt index.",{"slug":693,"published":694,"minutes":593,"category":7,"tags":695,"keywords":701,"about":709,"sources":718,"cover":725,"og":726,"expertise":71,"locales":727,"lang":73,"title":728,"description":729,"coverAlt":730},"multiplayer-sailing-game-threejs","2026-10-05",[696,697,698,699,700],"three.js","WebGL","GLSL","WebSockets","Game development",[702,703,704,705,706,707,708,698],"three.js game","three.js multiplayer","Gerstner waves","WebGL ocean shader","day night cycle three.js","WebSocket relay","browser game development",[710,713,715],{"name":711,"url":712},"Three.js","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FThree.js",{"name":697,"url":714},"https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FWebGL",{"name":716,"url":717},"WebSocket","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FWebSocket",[719,722],{"title":720,"url":721},"GPU Gems, Chapter 1: Effective Water Simulation from Physical Models","https:\u002F\u002Fdeveloper.nvidia.com\u002Fgpugems\u002Fgpugems\u002Fpart-i-natural-effects\u002Fchapter-1-effective-water-simulation-physical-models",{"title":723,"url":724},"RFC 6455: The WebSocket Protocol","https:\u002F\u002Fwww.rfc-editor.org\u002Frfc\u002Frfc6455","\u002Fimages\u002Fblog\u002Fmultiplayer-sailing-game-threejs\u002Fdusk.webp","\u002Fimages\u002Fblog\u002Fmultiplayer-sailing-game-threejs\u002Fog.jpg",[73,74,75],"Building a multiplayer 3D sailing game with plain three.js","Gerstner waves shared by GPU and CPU, one sky function for sky, water and fog, a five-minute day\u002Fnight cycle and a tiny WebSocket relay – the tech behind the game on my portfolio.","A dragon-prowed junk with glowing lanterns sailing across a choppy bay at dusk, forested hills on both sides.",{"slug":732,"published":733,"minutes":643,"category":7,"tags":734,"keywords":740,"about":749,"sources":758,"cover":771,"og":772,"expertise":71,"locales":773,"lang":73,"title":774,"description":775,"coverAlt":776},"home-assistant-ev-charging-energy-manager","2026-09-16",[735,736,737,738,739],"Home Assistant","EPEX Spot","Energy prices","Tesla","Energy management",[741,742,743,744,745,746,747,748],"EPEX Austria prices","spot tariff Austria EV charging","Home Assistant EV charging","cheapest hours charging","day-ahead price Austria","negative electricity prices Austria","Tesla charging spot prices","load management 3 phase",[750,753,755],{"name":751,"url":752},"EPEX SPOT","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FEPEX_SPOT",{"name":735,"url":754},"https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FHome_Assistant",{"name":756,"url":757},"Load management","https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FLoad_management",[759,762,765,768],{"title":760,"url":761},"aWATTar Austria: market data API","https:\u002F\u002Fwww.awattar.at\u002Fservices\u002Fapi",{"title":763,"url":764},"EPEX SPOT: market results","https:\u002F\u002Fwww.epexspot.com\u002Fen\u002Fmarket-results",{"title":766,"url":767},"Home Assistant developer docs: apps","https:\u002F\u002Fdevelopers.home-assistant.io\u002Fdocs\u002Fapps\u002F",{"title":769,"url":770},"Home Assistant: Tesla Fleet integration","https:\u002F\u002Fwww.home-assistant.io\u002Fintegrations\u002Ftesla_fleet\u002F","\u002Fimages\u002Fblog\u002Fhome-assistant-ev-charging-energy-manager\u002Fcover.webp","\u002Fimages\u002Fblog\u002Fhome-assistant-ev-charging-energy-manager\u002Fog.jpg",[73,74,75],"Charging on EPEX Austria prices: what my Home Assistant app saves","A year of hourly EPEX prices for Austria, replayed for a Tesla and a water boiler: cheapest-hour charging costs 6.5 instead of 18.9 ct\u002FkWh, close to €590 a year, without blowing a 20 A fuse.","Cover: three bars comparing 18.9, 13.1 and 6.5 ct\u002FkWh for charging at 18:00, overnight and in the cheapest hours.",1792177152232]