{"course":{"slug":"building-with-ai","title":"Building with AI","url":"https://addaly.com/learn/building-with-ai","level":"intermediate","track":"build"},"lesson":{"slug":"reading-the-response","title":"Every field in the response, and the two that catch bugs","url":"https://addaly.com/learn/building-with-ai/reading-the-response","position":6,"of":80,"minutes":8,"answer":"Check stop_reason before you parse and log the returned model string and usage on every call.","keyIdea":"Check stop_reason before you parse and log the returned model string and usage on every call, because a truncated answer and a silently swapped model version both look like a working response until they do not."},"body":{"format":"markdown","text":"## Most people read one field\n\nA typical first integration reaches straight for the text: `response.content[0].text`, or `response.choices[0].message.content`, and everything else in the object is ignored. That is where a specific class of production bug is born, because two of the discarded fields are the ones that tell you the answer is incomplete.\n\nHere is what comes back, and what each part is for.\n\n**`id`**: a unique identifier for this generation. Log it. When a user reports a bad answer three days later, the id is the only reliable way to find that exact call in the provider's dashboard and in your own traces.\n\n**`model`**: the model that actually served the request, which is not always the string you sent. Ask for an alias like `latest` and you get whatever the provider is currently pointing that alias at. Log the returned value, not the requested one. The morning your outputs shift for no reason you changed, this field is the evidence.\n\n**`role`**: always `assistant`. Present for symmetry with the messages you sent.\n\n**`content`**: a **list of blocks**, not a string. A response can contain a text block, then a tool-use block, then more text. Any code written as `content[0].text` is one tool call away from a crash or, worse, from silently dropping half the answer. Iterate over blocks and switch on the type.\n\n**`stop_reason`** (or `finish_reason`): why generation ended. This is the field that catches bugs.\n\n**`usage`**: token counts. This is the field that controls your bill.\n\n## stop_reason, value by value\n\n- **`end_turn` / `stop`**: the model finished naturally. This is the only value that means the answer is complete.\n- **`max_tokens` / `length`**: it ran out of budget mid-sentence. The text you have is **truncated**. If you were expecting JSON, you have half an object. Parsing it will throw, or, if you were unlucky enough to be building a string, it will succeed with missing fields.\n- **`tool_use` / `tool_calls`**: it wants you to run something and come back. Not an answer.\n- **`stop_sequence`**: it hit a string you told it to stop at.\n- **`refusal`** or a content-filter reason: it declined. Some providers return this as a normal completion with empty text, which is why blindly reading `content[0].text` can hand a user a blank screen with no error anywhere in your logs.\n\nThe rule, in one line: **check `stop_reason` before you touch the text.**\n\n```python\nif r.stop_reason == \"max_tokens\":\n    log.warning(\"truncated\", extra={\"id\": r.id})\n    raise Truncated()      # retry with a larger cap, do not parse\nif r.stop_reason == \"tool_use\":\n    return handle_tools(r)\n```\n\nThe most common form of this bug: extraction works for weeks, then a customer submits an unusually long invoice, the response hits `max_tokens`, the JSON is truncated, your parser throws, and your error message says `Unexpected end of JSON input`, which tells you nothing about the actual cause. One `if` at the top would have named it.\n\n## usage, and the four numbers\n\n`usage` reports at minimum `input_tokens` and `output_tokens`. On providers with prompt caching you also get `cache_creation_input_tokens` and `cache_read_input_tokens`, which are billed at very different rates: cache reads are often around a tenth of the normal input price, and cache writes a little more than normal.\n\nWrite one function and call it from every code path:\n\n```python\ndef record(r, feature, user_id):\n    cost = (r.usage.input_tokens  * IN_RATE\n          + r.usage.output_tokens * OUT_RATE) / 1_000_000\n    metrics.increment(\"llm.cost\", cost, tags=[f\"feature:{feature}\"])\n```\n\nWithout per-feature tagging, your provider dashboard gives you one number for the whole company and you cannot answer the question that always gets asked: *which feature is spending the money?* That question arrives about a week after launch, and reconstructing the answer from historical logs you did not write is not possible.\n\n## What is not in the response\n\nTwo absences worth naming.\n\nThere is **no confidence score**. Nothing in the response tells you how likely the answer is to be right. Some providers expose `logprobs`, which give the model's probability for each token it chose, useful for detecting that the model was torn between two spellings, close to useless as a measure of factual correctness. A model can assign 0.99 to every token of a confidently invented citation.\n\nThere is **no indication of what the model used**. You cannot tell from the response whether an answer came from a document you supplied or from parameters trained two years ago. If you need that distinction (and for anything factual you do) you have to design for it by making the model quote and cite, then verifying the quote appears in your source. The response object will not do it for you."},"check":{"question":"An extraction service that has run cleanly for months starts throwing `Unexpected end of JSON input` on a handful of documents. What is the first thing to check, and why?","options":[{"text":"Whether the JSON schema needs loosening, since some documents evidently have fields it does not allow.","correct":false,"tempting":"Treats a parse failure as a validation failure; the parser never reached validation because the text was not complete JSON."},{"text":"Whether those documents contain characters that break JSON encoding.","correct":false,"tempting":"Plausible for hand-built strings, but well-formed model output escapes characters correctly, and this would fail consistently rather than on the longest inputs."},{"text":"Whether `stop_reason` is `max_tokens` on those calls, meaning the answer was cut off mid-object and never was valid JSON.","correct":true},{"text":"Whether the provider is under load and returning degraded responses.","correct":false,"tempting":"Reaches for an external cause without inspecting the response fields that would identify a local one, and load does not truncate output mid-structure."}]},"prev":{"slug":"streaming-and-perceived-speed","title":"Streaming, and the difference between fast and feeling fast","url":"https://addaly.com/learn/building-with-ai/streaming-and-perceived-speed"},"next":{"slug":"retries-that-do-not-make-it-worse","title":"Errors, retries, and how a retry loop takes down your own service","url":"https://addaly.com/learn/building-with-ai/retries-that-do-not-make-it-worse"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Every field in the response, and the two that catch bugs', in Building with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-with-ai/reading-the-response (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Every field in the response, and the two that catch bugs","courseTitle":"Building with AI","module":{"index":1,"slug":"the-call-underneath-everything","title":"The call underneath everything"},"answer":null,"keyIdea":"Check stop_reason before you parse and log the returned model string and usage on every call, because a truncated answer and a silently swapped model version both look like a working response until they do not.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"most-people-read-one-field","runs":[{"text":"Most people read one field"}]},{"t":"p","runs":[{"text":"A typical first integration reaches straight for the text: "},{"text":"response.content[0].text","code":true},{"text":", or "},{"text":"response.choices[0].message.content","code":true},{"text":", and everything else in the object is ignored. That is where a specific class of production bug is born, because two of the discarded fields are the ones that tell you the answer is incomplete."}]},{"t":"p","runs":[{"text":"Here is what comes back, and what each part is for."}]},{"t":"p","runs":[{"text":"id","b":true,"code":true},{"text":": a unique identifier for this generation. Log it. When a user reports a bad answer three days later, the id is the only reliable way to find that exact call in the provider’s dashboard and in your own traces."}]},{"t":"p","runs":[{"text":"model","b":true,"code":true},{"text":": the model that actually served the request, which is not always the string you sent. Ask for an alias like "},{"text":"latest","code":true},{"text":" and you get whatever the provider is currently pointing that alias at. Log the returned value, not the requested one. The morning your outputs shift for no reason you changed, this field is the evidence."}]},{"t":"p","runs":[{"text":"role","b":true,"code":true},{"text":": always "},{"text":"assistant","code":true},{"text":". Present for symmetry with the messages you sent."}]},{"t":"p","runs":[{"text":"content","b":true,"code":true},{"text":": a "},{"text":"list of blocks","b":true},{"text":", not a string. A response can contain a text block, then a tool-use block, then more text. Any code written as "},{"text":"content[0].text","code":true},{"text":" is one tool call away from a crash or, worse, from silently dropping half the answer. Iterate over blocks and switch on the type."}]},{"t":"p","runs":[{"text":"stop_reason","b":true,"code":true},{"text":" (or "},{"text":"finish_reason","code":true},{"text":"): why generation ended. This is the field that catches bugs."}]},{"t":"p","runs":[{"text":"usage","b":true,"code":true},{"text":": token counts. This is the field that controls your bill."}]},{"t":"h2","id":"stopreason-value-by-value","runs":[{"text":"stop_reason, value by value"}]},{"t":"ul","items":[[{"text":"end_turn","b":true,"code":true},{"text":" / ","b":true},{"text":"stop","b":true,"code":true},{"text":": the model finished naturally. This is the only value that means the answer is complete."}],[{"text":"max_tokens","b":true,"code":true},{"text":" / ","b":true},{"text":"length","b":true,"code":true},{"text":": it ran out of budget mid-sentence. The text you have is "},{"text":"truncated","b":true},{"text":". If you were expecting JSON, you have half an object. Parsing it will throw, or, if you were unlucky enough to be building a string, it will succeed with missing fields."}],[{"text":"tool_use","b":true,"code":true},{"text":" / ","b":true},{"text":"tool_calls","b":true,"code":true},{"text":": it wants you to run something and come back. Not an answer."}],[{"text":"stop_sequence","b":true,"code":true},{"text":": it hit a string you told it to stop at."}],[{"text":"refusal","b":true,"code":true},{"text":" or a content-filter reason: it declined. Some providers return this as a normal completion with empty text, which is why blindly reading "},{"text":"content[0].text","code":true},{"text":" can hand a user a blank screen with no error anywhere in your logs."}]]},{"t":"p","runs":[{"text":"The rule, in one line: "},{"text":"check ","b":true},{"text":"stop_reason","b":true,"code":true},{"text":" before you touch the text.","b":true}]},{"t":"code","lang":"python","label":"Python","text":"if r.stop_reason == \"max_tokens\":\n    log.warning(\"truncated\", extra={\"id\": r.id})\n    raise Truncated()      # retry with a larger cap, do not parse\nif r.stop_reason == \"tool_use\":\n    return handle_tools(r)"},{"t":"p","runs":[{"text":"The most common form of this bug: extraction works for weeks, then a customer submits an unusually long invoice, the response hits "},{"text":"max_tokens","code":true},{"text":", the JSON is truncated, your parser throws, and your error message says "},{"text":"Unexpected end of JSON input","code":true},{"text":", which tells you nothing about the actual cause. One "},{"text":"if","code":true},{"text":" at the top would have named it."}]},{"t":"h2","id":"usage-and-the-four-numbers","runs":[{"text":"usage, and the four numbers"}]},{"t":"p","runs":[{"text":"usage","code":true},{"text":" reports at minimum "},{"text":"input_tokens","code":true},{"text":" and "},{"text":"output_tokens","code":true},{"text":". On providers with prompt caching you also get "},{"text":"cache_creation_input_tokens","code":true},{"text":" and "},{"text":"cache_read_input_tokens","code":true},{"text":", which are billed at very different rates: cache reads are often around a tenth of the normal input price, and cache writes a little more than normal."}]},{"t":"p","runs":[{"text":"Write one function and call it from every code path:"}]},{"t":"code","lang":"python","label":"Python","text":"def record(r, feature, user_id):\n    cost = (r.usage.input_tokens  * IN_RATE\n          + r.usage.output_tokens * OUT_RATE) / 1_000_000\n    metrics.increment(\"llm.cost\", cost, tags=[f\"feature:{feature}\"])"},{"t":"p","runs":[{"text":"Without per-feature tagging, your provider dashboard gives you one number for the whole company and you cannot answer the question that always gets asked: "},{"text":"which feature is spending the money?","i":true},{"text":" That question arrives about a week after launch, and reconstructing the answer from historical logs you did not write is not possible."}]},{"t":"h2","id":"what-is-not-in-the-response","runs":[{"text":"What is not in the response"}]},{"t":"p","runs":[{"text":"Two absences worth naming."}]},{"t":"p","runs":[{"text":"There is "},{"text":"no confidence score","b":true},{"text":". Nothing in the response tells you how likely the answer is to be right. Some providers expose "},{"text":"logprobs","code":true},{"text":", which give the model’s probability for each token it chose, useful for detecting that the model was torn between two spellings, close to useless as a measure of factual correctness. A model can assign 0.99 to every token of a confidently invented citation."}]},{"t":"p","runs":[{"text":"There is "},{"text":"no indication of what the model used","b":true},{"text":". You cannot tell from the response whether an answer came from a document you supplied or from parameters trained two years ago. If you need that distinction (and for anything factual you do) you have to design for it by making the model quote and cite, then verifying the quote appears in your source. The response object will not do it for you."}]}],"check":{"question":[{"text":"An extraction service that has run cleanly for months starts throwing "},{"text":"Unexpected end of JSON input","code":true},{"text":" on a handful of documents. What is the first thing to check, and why?"}],"options":[{"text":[{"text":"Whether the JSON schema needs loosening, since some documents evidently have fields it does not allow."}],"correct":false,"tempting":[{"text":"Treats a parse failure as a validation failure; the parser never reached validation because the text was not complete JSON."}]},{"text":[{"text":"Whether those documents contain characters that break JSON encoding."}],"correct":false,"tempting":[{"text":"Plausible for hand-built strings, but well-formed model output escapes characters correctly, and this would fail consistently rather than on the longest inputs."}]},{"text":[{"text":"Whether "},{"text":"stop_reason","code":true},{"text":" is "},{"text":"max_tokens","code":true},{"text":" on those calls, meaning the answer was cut off mid-object and never was valid JSON."}],"correct":true,"tempting":null},{"text":[{"text":"Whether the provider is under load and returning degraded responses."}],"correct":false,"tempting":[{"text":"Reaches for an external cause without inspecting the response fields that would identify a local one, and load does not truncate output mid-structure."}]}],"order":[3,1,0,2],"why":null},"prev":{"slug":"streaming-and-perceived-speed","title":"Streaming, and the difference between fast and feeling fast","minutes":9},"next":{"slug":"retries-that-do-not-make-it-worse","title":"Errors, retries, and how a retry loop takes down your own service","minutes":9}}}