Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~32 min· topic 7 of 9

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 finish part.
Finish reason
Why the answer ended: stop, length, content-filter, tool-calls, error and others.
messageMetadataSchema
A zod schema given to useChat to 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. 1

    Make the schema strict

    In the first example make usage required by deleting .optional() from the usage line, and change the start line of messageMetadata to return only { model: "stand-in" }.

    The start metadata has no usage yet, so validation fails on every message: onError: AI_TypeValidationError prints even for the first, good question, and the chat never works. Optional fields are the safe default, because metadata arrives in pieces.

  2. 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 on start.

  3. 3

    Change the budget

    In the third example set BUDGET to 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

Metadata on start and finish, and a schema that rejects

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

Press Run to see the page.

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: user
A footer under each answer, including the answers with no usage

Four 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

Press Run to see the page.

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 unknown
Adding up a chat's tokens, and a budget

The 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

Press Run to see the page.

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 budget

Break 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);.

terminal
$ The console, last two lines
── what you'll see ──
stop: "one two three" | stand-in | undefined tokens, about $NaN
cut: "Orders ship in" | stand-in | undefined tokens, about $NaN

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 onEnd of streamText, tied to the user and the chat id. The browser's copy is for display; billing, budgets and abuse detection need the server's. Remember onEnd may not run for aborted requests (Topic 5.2), so pair it with onAbort.

  • ▸

    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 finishReason to drive the UI: length shows Continue, content-filter shows the refusal screen of Topic 9.6, error shows retry. This is more reliable than guessing from the text.

Remember this

  1. 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 (length means 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 a metadata field for exactly this.

  2. 2

    How it travels. On the server you pass messageMetadata to toUIMessageStreamResponse. The SDK calls it for every part of the stream, with { part }. Return an object for the parts where you have something to say, and undefined for the rest. Typical places: on start (the model name, a creation marker) and on finish, where part.totalUsage and part.finishReason are available. What you return is sent to the browser and merged into message.metadata; later values replace earlier ones with the same key.

  3. 3

    Usage. totalUsage has inputTokens, outputTokens, totalTokens and detail fields such as cached or reasoning tokens. Any of them can be undefined, 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. 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 first text-start part goes by, and send { firstTokenMs, totalMs } with the finish part (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 the finish part, so a stopped answer has none. Never log timings in a live example's output: they differ on every run.

  5. 5

    Types and validation. Define one metadata type shared by server and client: type MitraMessage = UIMessage<{ model?: string; usage?: Usage; finishReason?: string }>, and a matching zod schema passed as messageMetadataSchema to useChat. The hook validates incoming metadata against it. A metadata value that does not match the schema makes the whole request fail: the status becomes error and the assistant message is dropped (the first example shows it). So make most fields optional.

  6. 6

    When metadata never arrives. The finish part is the last thing the stream sends. If the user presses stop, the connection drops, or the stream fails, there is no finish, so there is no usage and no finish reason. The message has only the metadata from start. A screen that does message.metadata.usage.totalTokens crashes on exactly the answers you most want to inspect. Always write metadata?.usage?.totalTokens ?? null and show 'unknown'.

  7. 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. 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

01

What is the difference between a part and metadata, and between metadata and a data part?

02

Why is message.metadata.usage.totalTokens risky, and how should you write it?

03

How does the server decide which metadata to send, and when does the finish information become available?

04

What happens if the metadata sent by the server does not match messageMetadataSchema?

Practice

01

Show the finish reason content-filter as the notice 'Mitra cannot answer that question.' in the footer of the second example.

02

Write formatCost(m) that returns an empty string when the price table has no entry for the model, and a dollar amount otherwise.

03

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 start and finish with messageMetadata.

  • I can read message.metadata safely 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.