Topic 1.1
The Chat Request and Response
In one line
A chat request is a JSON list of messages, each with a role. The response is JSON too: the answer text, the reason the answer stopped, and token counts. Reading all three fields correctly is what separates a finished answer from a cut-off one.
Think of it like this
Posting a letter to a very patient help desk, with the whole conversation so far copied onto the page, because the desk forgets you the moment the letter is sent. The reply comes back in an envelope with three things: the answer, a note saying why the answer ends here ("finished" or "ran out of space"), and a line saying how much the reply cost. A chat request and response are that letter and that envelope, written in JSON.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- messages
- The list of everything said so far, in order. The model answers the last one, using the earlier ones as context.
- Role
- Who said a message:
system(the developer's instructions),user(the visitor) orassistant(the model). - System message
- A message from you that sets the model's job and rules. The visitor does not type it.
- choices
- The list of answers in a response. A chat UI reads
choices[0]. - finish_reason
- Why the model stopped writing:
stop(finished),length(hit the limit),content_filter(blocked by safety rules). - usage
- Token counts for the request: tokens in, tokens out, and the total.
- Token
- A small piece of text the model counts in. Roughly a short word or part of a longer one. Billing and limits use tokens.
- OpenAI-compatible
- A request and response shape that many providers copy, so code written for one often works with another.
Step by step
01The request body
This is everything a chat endpoint needs. The first message is yours and tells the model its job. The second is the visitor's question. The model answers the last message, with the earlier ones as background.
{
"model": "provider/model-name",
"stream": false,
"messages": [
{ "role": "system", "content": "You are Mitra, the help assistant of Chai & Co." },
{ "role": "user", "content": "When will my order ship?" }
]
}02The response body
The text to show is nested three levels down. The first live example prints each part so you can see where it sits. Notice that choices is a list, message is an object with a role (always assistant here) and the content.
{
"id": "chatcmpl-…",
"object": "chat.completion",
"model": "provider/model-name",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Orders ship in 1 to 2 working days." },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 42, "completion_tokens": 8, "total_tokens": 50 }
}03Read it in the right order
Good code reads the response in a fixed order. (1) Was the HTTP status ok? (Topic 1.3.) (2) Is there a choices[0]? (3) Why did the model stop (finish_reason)? (4) Is there text? (5) Only then decide what to show. The second live example runs four different replies through one function that does exactly this.
04Edge cases: other alphabets, empty lists, bad bodies
The third live example sends a question in Hindi and gets back Hindi text with an emoji. JSON carries any text, so nothing special is needed, but notice how many more prompt tokens a long question uses: a question of 300 repeated words is far larger than a short one, and you pay for it.
The same example sends three broken requests. The endpoint answers each with 400 and a body shaped { error: { message, type } }. That error shape is the other half of the contract, and Topic 1.3 shows how to handle it.
05What the screen needs from all this
Four decisions follow from the fields: what to show for a complete answer, for a cut-off one, for a blocked one and for an empty one. Mitra v1 (Topic 1.6) makes these four decisions in one small function, and every later phase builds on it.
Try it yourself
- 1
Change the order of the messages
In the first example, add an
assistantmessage between the system and user messages, for instance{ role: "assistant", content: "Hello! How can I help?" }, and run again.The
prompt_tokensnumber grows (from 42 to 56 with the stand-in's estimate), because every message you send is counted and billed on every request. This is why long conversations cost more with each turn (Topic 4.3). - 2
Make a reply that is cut off
In the second example, replace the first reply with
{ text: "Returns are accepted within", finish: "length" }, and look at the first line of output.The stand-in says
lengthand the text stops mid-sentence, with HTTP 200 and no error. Check what the screen function does with it. A page that showscontentalone would hide the problem. - 3
Count characters and tokens
In the third example, change the long question to
"चाय ".repeat(300)and compare the prompt tokens with the English version.The prompt tokens go from 233 to 308. The stand-in only counts one token per four characters, and "चाय " is four characters where "Hi " is three, so the rise here is modest. A real tokenizer usually splits Devanagari and other non-Latin scripts into many more pieces per character, so the real rise is much bigger. The lesson: length in characters is not a safe guide to cost, so read the provider's
usage.
Code & diagrams
Not a real model: the reply is scripted, and the token counts are estimates from the stand-in. In a real app this endpoint is your provider's, and your own route (Topic 1.5) calls it with a secret key. Try changing the system message and the question: the reply stays the same because it is scripted.
Preview
Console
Nothing logged yet.Expected console output
status: 200 true
content-type: application/json
top-level keys: [ 'id', 'object', 'model', 'choices', 'usage' ]
choices: 1
message: { role: 'assistant', content: 'Orders ship in 1 to 2 working days.' }
finish_reason: stop
usage: { prompt_tokens: 42, completion_tokens: 8, total_tokens: 50 }
the text to show: Orders ship in 1 to 2 working days.All four replies came back with HTTP 200. Only `finish_reason` and the text tell them apart. The second one stops after the word "and": without the check, a page would show that as a finished answer. The step `finish: "content-filter"` is how the stand-in scripts a blocked answer; a real provider reports it as `content_filter`.
Preview
Console
Nothing logged yet.Expected console output
"returns?" → stop "Returns are accepted within 7 days."
screen: { show: 'Returns are accepted within 7 days.', mark: 'complete' }
"tell me about chai" → length "Our masala chai is black tea blended with cardamom, ginger and"
screen: { show: 'Our masala chai is black tea blended with cardamom, ginger and…', mark: 'cut off' }
"something blocked" → content_filter ""
screen: { show: 'Mitra cannot answer that. Try asking in a different way.', mark: 'blocked' }
"something empty" → stop ""
screen: { show: 'Mitra did not have an answer. Please try again.', mark: 'empty' }Text in any script and emoji travel through JSON unchanged. A real endpoint rejects bad bodies the same way: a 400 with an `error` object. The token numbers here are the stand-in's estimate (about one token per four characters of JSON); a real tokenizer gives different numbers, and often more per character for non-Latin text.
Preview
Console
Nothing logged yet.Expected console output
Hindi and emoji reply: 200 ☕ चाय को दूध और मसाले के साथ उबाला जाता है। 🙂
empty messages list: 400 { message: '\'messages\' is required and must not be empty.', type: 'invalid_request_error' }
no messages field: 400 { message: '\'messages\' is required and must not be empty.', type: 'invalid_request_error' }
body is not JSON: 400 { message: 'The request body must be JSON.', type: 'invalid_request_error' }
prompt tokens, short question: 8
prompt tokens, long question: 233Reply 1 has `content: null` because the model asked for a tool (Phase 8): `?? ""` turns it into an empty string so `.trim()` cannot throw. Reply 2 has a finish reason this code has never seen: it still shows the text, and the state tells you to look. Replies 3 and 4 have no `choices` at all, and 1 to 4 have no `usage`: nothing crashes, nothing prints "undefined". This function is the answer to practice question 1.
Preview
Console
Nothing logged yet.Expected console output
0 { text: 'Fine.', state: 'complete' } usage: 7
1 { text: '', state: 'empty' } usage: none
2 { text: 'Hello', state: 'unknown-reason:some_new_reason' } usage: none
3 { text: '', state: 'empty' } usage: none
4 { text: '', state: 'empty' } usage: noneBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Read the text from the wrong place
In the first example, replace the line that prints choices with console.log("message:", data.message.content);.
Break #2
Show the text and ignore finish_reason
In the second example, change whatToShow to only return { show: choice.message.content, mark: "complete" }; and run.
Myth vs fact
Myth
A response with status 200 is a good answer.
Fact
200 only means the request worked. The answer can be cut off (length), blocked (content_filter) or empty. Check finish_reason and the text.
Myth
choices has one item, so I can skip the list.
Fact
It is a list because a request can ask for several alternatives. Reading choices[0] is right for chat, but check that it exists.
Myth
The model remembers the conversation.
Fact
It remembers nothing between requests. The only memory is the messages list you send, which is why history costs tokens on every turn.
Myth
Token count equals word count.
Fact
A token is a piece of text, often part of a word. Counts differ by language and by model, so use the usage numbers the provider reports.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Treat the provider's JSON as untrusted input to your own code. Even a well-behaved provider can change a field, add a new
finish_reasonvalue or omitusage(for example on a gateway that does not report it). Read with optional chaining and give everyswitchonfinish_reasonadefaultbranch that shows a safe message. - ▸
Keep the system message on the server (Topic 1.5). A system message in browser code is readable by every visitor, who can then copy your instructions and craft inputs that argue with them. The browser sends the conversation, and the server adds the system message and the model name.
- ▸
usageis your best free measurement. Logprompt_tokensandcompletion_tokensper request on the server from day one (Topic 13.5). A conversation that quietly grows from 300 to 6,000 prompt tokens is the usual cause of a surprise bill, and you can only see it if you recorded the numbers.
Remember this
- 1
The problem this solves: Every provider answers in a shape, and screens that read it carelessly break in quiet ways. A page that only shows the text will show a sentence that stopped in the middle as if it were complete. A page that assumes there is always text will crash on an empty answer. This topic goes through each field of the request and the response, so you know what you can rely on and what you must check.
- 2
The request. You send an HTTP
POSTwith a JSON body. The important field ismessages: a list, in order, of everything said so far. Each message has aroleand acontent. There are three roles you use now:system(instructions from you, the developer: who the assistant is and what it may talk about),user(what the visitor typed), andassistant(what the model said earlier). A fourth role,tool, appears in Phase 8. A request usually also carries the name of the model, whether you want streaming (stream), and optional limits such as the longest answer allowed (the field's name differs between providers). - 3
The model has no memory. It does not remember your last request. If the visitor asks "And what about returns?", the model only understands "And" if the earlier turns are in
messagesagain. So every request resends the conversation. Topic 4.3 covers what that costs and how to trim it. For now, a conversation is onesystemmessage followed by oneusermessage. - 4
The response (this course uses the OpenAI-compatible shape). Many providers and gateways speak this shape, so one parser works across them. Others, such as Anthropic's and Google's native APIs, use their own shapes, which is one reason Phase 5 introduces an SDK. A non-streamed reply has
id,model, a listchoices, andusage. The answer lives atchoices[0].message.content.choicesis a list because a request can ask for several alternative answers; in a chat UI you ask for one and read the first. - 5
**
finish_reasonsays why the model stopped, and it is not an error.** The HTTP status is still200.stopmeans the model reached a natural end: the answer is complete.lengthmeans it hit the limit on answer length and was cut off, possibly in the middle of a sentence.content_filtermeans the provider's safety system stopped or blocked the answer. (A fourth value,tool_calls, means the model wants your code to run a tool: Phase 8.) A screen that ignoresfinish_reasonshows cut-off and blocked answers as if they were finished. - 6
**
usagecounts tokens, not words.**prompt_tokensis what you sent,completion_tokensis what the model wrote,total_tokensis their sum. You are billed by these numbers, and the model has a maximum it can handle per request. Tokens are not characters: a short English word is often one token, a long word several, and text in some non-Latin scripts and emoji often takes more tokens per character, so the same sentence can cost more in Hindi or Japanese than in English. The numbers in this course's examples come from a stand-in model that only estimates; a real provider counts with its own tokenizer. - 7
An empty answer is a valid answer. The response can arrive with
200, a normal shape andcontentempty (""). Some providers send no text at all when the model asked for a tool instead. A safety filter can also leave nothing to show. When the model asks for a tool,contentcan even benull(not a string), andnull.trim()throws. Treatcontentas possibly empty,nullor missing, and decide what the screen says in that case, because "nothing" on screen looks like a bug.
Explain it without notes
What does a chat request contain, and why is the whole conversation sent every time?
A response arrives with HTTP 200. List three reasons the answer might still be unusable on screen.
What does finish_reason: "length" mean, and what should the UI do about it?
What does usage report, and why can it differ from a character count?
Practice
Write a function readReply(data) that returns { text, state } where state is "complete", "cut-off", "blocked" or "empty", and never throws, even if data has no choices.
Add a line under each answer in the first example that shows usage.total_tokens as Used 50 tokens, and hides the line when usage is missing.
Trade-offs
- ↔
Reading the whole response at once (this phase) is simple and lets you validate everything before showing it, at the cost of the visitor waiting for the full answer. Streaming (Phase 2) shows text sooner but makes validation harder.
- ↔
Following the OpenAI-compatible shape lets one parser work against many providers, but providers differ in details such as the name of the length-limit field and how they report errors, so keep the parsing in one small function you can adjust.
Done when you can
I can write a chat request body with a system and a user message.
I can find the answer text, the finish reason and the token counts in a response.
I can name the three finish reasons and say what each means for the screen.
I can show a sensible message for a cut-off, a blocked and an empty answer.
I can explain why each request carries the whole conversation.