Topic 5.7
Message Metadata and Usage
In one line
Parts carry what the answer says. Metadata carries facts about the answer: which model wrote it, how many tokens it used, why it ended. The server attaches metadata with messageMetadata, the browser reads message.metadata, and the screen must cope with the cases where it never arrives.
Think of it like this
The delivery slip stapled to a parcel. The parcel is the content. The slip says who sent it, how heavy it is, and whether it was opened on the way. You do not read the slip to know what is inside, but when something goes wrong, the slip is the first thing anyone asks for. Metadata is the slip: facts about a message, kept next to it but not part of it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Metadata
- Extra facts attached to a message, available as
message.metadata, separate from its parts. - messageMetadata
- The server callback that decides what metadata to send for each stream part.
- Usage
- The token counts a provider reports for a call: input, output and total.
- totalUsage
- Usage added up over all steps of a multi-step answer. It is on the
finishpart. - Finish reason
- Why the answer ended:
stop,length,content-filter,tool-calls,errorand others. - messageMetadataSchema
- A zod schema given to
useChatto validate incoming metadata. - Price table
- Your own configuration of cost per million input and output tokens. Update it from your provider's current price list.
Step by step
01Send it on start and on finish
The first example attaches { model, phase: "started" } on the start part and the usage and finish reason on finish. A script waits for the stream's own events and prints each different metadata value the message holds. You see the early value while the answer streams and the complete one at the end.
Then it sends metadata that does not match the schema, to show the failure: the request ends in error and the assistant message is gone.
02A footer under each answer
The second example renders a footer under every assistant message: model, tokens, estimated cost, and a notice when the finish reason is length. It then runs four answers: a normal one, one cut by the length limit, one stopped by the user after three words, and one that broke mid-stream. The last two have no usage, and the footer says so rather than crashing.
03When the number never arrives
Usage rides on the finish part. A stopped answer, a dropped connection and a failed stream do not send it. In the second example the last two answers show 'usage unknown'. Reading m.tokens directly would print undefined and NaN, which the breakIt shows.
04A running total with a budget
The third example adds up the tokens of all answers in a chat and warns at a limit. Answers with unknown usage are counted separately instead of as zero, because a total that silently ignores them under-reports the real cost.
Try it yourself
- 1
Make the schema strict
In the first example make
usagerequired by deleting.optional()from theusageline, and change thestartline ofmessageMetadatato return only{ model: "stand-in" }.The start metadata has no usage yet, so validation fails on every message:
onError: AI_TypeValidationErrorprints even for the first, good question, and the chat never works. Optional fields are the safe default, because metadata arrives in pieces. - 2
Read the metadata of a stopped answer
In the second example add
console.log(JSON.stringify(chat.messages.at(-1).metadata))to the loop and compare the four endings.The normal and the length answers have usage and a reason. The stopped and the broken answers have only
{"model":"stand-in"}, the value sent onstart. - 3
Change the budget
In the third example set
BUDGETto 60 and run it.The chat refuses after fewer questions. Notice that this check happens on the page, where anyone can bypass it. A server-side budget is the one that counts.
Code & diagrams
The token numbers come from the stand-in model, which counts words; a real provider reports its own. The early value (model, phase) is what a message holds while it streams; the usage arrives with the last chunk. After the bad value the assistant message is gone, so the second question shows no answer at all.
Preview
Console
Nothing logged yet.Expected console output
streaming: {"model":"stand-in","phase":"started"}
ready: {"model":"stand-in","phase":"finished","reason":"stop","usage":{"input":21,"output":8,"total":29}}
--- metadata that does not match the schema
onError: AI_TypeValidationError
status: error | last message role: userFour endings, four footers, no crash. The two answers that did not finish have no token numbers, because the final chunk (with the usage) never arrived. The footer says 'usage unknown' instead of showing zero.
Preview
Console
Nothing logged yet.Expected console output
normal: "Orders ship in 1 to 2 working days." | stand-in | 26 tokens, about $0.000021
length: "The return window is 7" | stand-in | 64 tokens, about $0.000037 | cut off: the answer reached the length limit
stop: "one two three" | stand-in | usage unknown
cut: "Orders ship in" | stand-in | usage unknownThe conversation grows, so each question costs more input tokens than the one before (Topic 4.3), and the total climbs faster than the number of questions. The unknown answers are reported, never counted as zero. The budget check is a guide for the screen; the real limit belongs on the server (Topic 3.6).
Preview
Console
Nothing logged yet.Expected console output
after question 1: 1 answers with usage, 0 without | total so far: 26 tokens
after question 2: 2 answers with usage, 0 without | total so far: 94 tokens
after question 3: 2 answers with usage, 1 without | total so far: 94 tokens (at least; 1 unknown)
after question 4: 3 answers with usage, 1 without | total so far: 239 tokens (at least; 1 unknown)
question 5 refused: this chat has used its token budgetBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Assume usage is always there
In the second example change const usage = typeof m.tokens === "number" ? … : "usage unknown"; to const usage = m.tokens + " tokens, about $" + cost(m).toFixed(6);.
Myth vs fact
Myth
Every answer has token usage.
Fact
Only answers that reach the end of the stream. Stopped, dropped and failed answers have none, and some providers do not report usage at all.
Myth
Metadata is private to my server.
Fact
It is sent to the browser and saved with the message. Anything in it can be read by the user.
Myth
A strict schema is safer.
Fact
It makes any small server change break every chat. Validate the fields you use and let the rest through.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Record usage on the server as well, in
onEndofstreamText, tied to the user and the chat id. The browser's copy is for display; billing, budgets and abuse detection need the server's. RememberonEndmay not run for aborted requests (Topic 5.2), so pair it withonAbort. - ▸
Cached input tokens are cheaper at many providers and are reported in
inputTokenDetails. If your prompt has a long fixed system part, watch that field to see whether caching works (the model side is in/llm-engineering). - ▸
Use a metadata
finishReasonto drive the UI:lengthshows Continue,content-filtershows the refusal screen of Topic 9.6,errorshows retry. This is more reliable than guessing from the text.
Remember this
- 1
The problem this solves: A screen often needs facts that are not the answer: which model was used (for support), how many tokens the answer cost (for a usage meter), why it stopped (
lengthmeans cut off). Putting them inside the text would show them to the user. Putting them in a side channel makes them likely to get lost. The SDK gives each message ametadatafield for exactly this. - 2
How it travels. On the server you pass
messageMetadatatotoUIMessageStreamResponse. The SDK calls it for every part of the stream, with{ part }. Return an object for the parts where you have something to say, andundefinedfor the rest. Typical places: onstart(the model name, a creation marker) and onfinish, wherepart.totalUsageandpart.finishReasonare available. What you return is sent to the browser and merged intomessage.metadata; later values replace earlier ones with the same key. - 3
Usage.
totalUsagehasinputTokens,outputTokens,totalTokensand detail fields such as cached or reasoning tokens. Any of them can beundefined, because a provider may not report them. Cost is tokens times your provider's current price per token. Keep prices in configuration you update, never copied from a blog post into code. The prices in the live examples are made-up numbers for the demo. - 4
Timing. Measure time on the server, where the clock is not affected by the user's network: note
Date.now()when the route starts, remember it when the firsttext-startpart goes by, and send{ firstTokenMs, totalMs }with thefinishpart (the practice below shows it). Time to first token is the number users feel; total time depends on the answer's length. Like usage, timing arrives only on thefinishpart, so a stopped answer has none. Never log timings in a live example's output: they differ on every run. - 5
Types and validation. Define one metadata type shared by server and client:
type MitraMessage = UIMessage<{ model?: string; usage?: Usage; finishReason?: string }>, and a matchingzodschema passed asmessageMetadataSchematouseChat. The hook validates incoming metadata against it. A metadata value that does not match the schema makes the whole request fail: the status becomeserrorand the assistant message is dropped (the first example shows it). So make most fields optional. - 6
When metadata never arrives. The
finishpart is the last thing the stream sends. If the user presses stop, the connection drops, or the stream fails, there is nofinish, so there is no usage and no finish reason. The message has only the metadata fromstart. A screen that doesmessage.metadata.usage.totalTokenscrashes on exactly the answers you most want to inspect. Always writemetadata?.usage?.totalTokens ?? nulland show 'unknown'. - 7
Metadata or a data part? Use metadata for facts about the whole message (model, usage, finish reason, a timestamp). Use a data part for something that has a place in the message and is rendered there (a progress card, a chart, a table) and may update by id (Topic 5.4). Metadata is small and merged; data parts are ordered content.
- 8
What not to put in it. Metadata goes to the browser and into every saved chat. Do not include the system prompt, internal user ids, other customers' data, or provider account details. It is also free for the user to read, so never show 'this is what the AI cost us' to customers unless you mean to.
Explain it without notes
What is the difference between a part and metadata, and between metadata and a data part?
Why is message.metadata.usage.totalTokens risky, and how should you write it?
How does the server decide which metadata to send, and when does the finish information become available?
What happens if the metadata sent by the server does not match messageMetadataSchema?
Practice
Show the finish reason content-filter as the notice 'Mitra cannot answer that question.' in the footer of the second example.
Write formatCost(m) that returns an empty string when the price table has no entry for the model, and a dollar amount otherwise.
Send the time to the first word and the total time with each finished answer.
Trade-offs
- ↔
Showing token counts and cost builds trust with developers and power users, but customers of a shop assistant do not care and may be confused. Keep detailed usage for an admin or debug view.
- ↔
Putting usage in message metadata keeps it next to the answer and in saved chats, at the price of exposing it to the browser. Server-side logs are private but need a join to find the answer they belong to.
Done when you can
I can attach metadata on
startandfinishwithmessageMetadata.I can read
message.metadatasafely when fields are missing.I can say why a stopped answer has no usage.
I can validate metadata with a lenient schema and know what happens when it fails.
I can choose between metadata and a data part.