You ask a model endpoint for a summary, the text arrives piece by piece, the connection closes, and your handler writes the result to storage. Nothing in that sequence proves the answer was complete, because a dropped connection and a finished answer both end with the same quiet EOF. Truncation is one of the least visible failure modes of streamed responses: no exception is raised, no retry is triggered, and the shortened text usually still reads like a plausible answer. This article describes a client-side completeness contract plus an illustrative test plan you can adapt, with clearly labeled limits on what such a check can and cannot prove.
Why a clean EOF is not evidence of completion
Streaming APIs deliver content in fragments, so completeness is a property of the protocol conversation, not of your code reaching the end of a loop. Several signals get conflated in practice, and separating them is the first step toward an honest check.
- Transport close: the socket or HTTP response ended. This says nothing about whether the producer finished its work.
- Absence of an exception: your client saw no error. Proxies, idle timeouts, and truncated payloads can all terminate cleanly.
- HTTP 200: the request was accepted. Status codes describe the start of the exchange, not its ending.
-
Terminal sentinel: many OpenAI-compatible streaming formats end with a sentinel such as
[DONE], and the final chunk usually carries afinish_reason. Confirm the exact shape against the specific provider's API reference before depending on it, since this varies.
A useful mental model: everything you can verify on the client is a necessary condition, never a sufficient one. You cannot prove the server finished; you can only prove that the signals you require actually arrived.
The completeness contract
Before writing any code, decide which conditions make a response acceptable in your context. A workable default set looks like this:
- The terminal sentinel was observed, not merely assumed.
- A
finish_reasonwas seen and belongs to an allow-list you chose deliberately. - The accumulated body passes your own structural checks (JSON parse, required keys, size floor).
- The verdict, the reason, and a content hash are recorded together for later correlation.
The code below is illustrative and was not executed or tested against any provider. It shows the shape of the check, not a production-hardened client.
# Illustrative only: adapt the transport and the field names to your provider.
import json, hashlib
ALLOWED_FINISH = {'stop'} # 'length' means the answer was cut on purpose; treat it as you see fit.
def read_stream(resp, expect_json=False):
sentinel_seen = False
finish_reason = None
parts = []
for raw in resp.iter_lines(decode_unicode=True):
if not raw:
continue
line = raw.strip()
if not line.startswith('data:'):
continue # comment lines, event names, and ids are ignored on purpose
payload = line[len('data:'):].strip()
if payload == '[DONE]':
sentinel_seen = True
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
return None, {'ok': False, 'why': 'unparsable_chunk', 'excerpt': payload[:120]}
for choice in chunk.get('choices', []):
delta = choice.get('delta', {}).get('content')
if delta:
parts.append(delta)
if choice.get('finish_reason'):
finish_reason = choice['finish_reason']
text = ''.join(parts)
verdict = {
'ok': True,
'sentinel': sentinel_seen,
'finish_reason': finish_reason,
'chars': len(text),
'sha256': hashlib.sha256(text.encode('utf-8')).hexdigest(),
}
if not sentinel_seen:
verdict.update(ok=False, why='no_sentinel')
elif finish_reason not in ALLOWED_FINISH:
verdict.update(ok=False, why='finish_reason=' + str(finish_reason))
elif expect_json:
try:
json.loads(text)
except json.JSONDecodeError:
verdict.update(ok=False, why='body_not_json')
return text, verdict
Two details that are easy to get wrong
The hash is a correlation tool, not a determinism test. Identical inputs do not guarantee identical model output, so a hash mismatch between two runs proves nothing by itself; it only helps you match a stored record to a captured stream.
A finish_reason of length is a policy decision rather than a bug in your client. If your downstream consumer needs complete sentences or valid JSON, a length-capped answer must be treated as incomplete even though the protocol ended normally.
Illustrative test plan: what to try, observe, and verify
The point of a test plan here is to confirm your validator fails loudly for the right reasons. Treat each row as something to script against a local stub first, then against the real endpoint in a non-critical path.
| Fault to inject | How to inject it | Observe | Required verdict |
|---|---|---|---|
| Mid-stream disconnect | A local proxy that closes the socket after N bytes | EOF with no sentinel | ok=False, why=no_sentinel |
| Silent body trim | Rewrite the response to drop trailing chunks | Short text, no error raised | ok=False, why=no_sentinel |
| Length cap | Set a very small output-token limit | A valid sentinel plus a length finish reason |
ok=False per your policy |
| Malformed fragment | Replay a recorded stream with one chunk corrupted | Parse failure at that fragment | ok=False, why=unparsable_chunk |
| Structured answer cut short | Truncate JSON mid-object | Partial braces | ok=False, why=body_not_json |
For each run, write down three things in the same log record: the verdict, the raw reason string, and the byte count actually received. Without the byte count you cannot distinguish a genuinely short answer from a stream that stopped early.
From observation to a decision
A validator that only logs is half a tool. Decide in advance what each outcome means for your workflow, so the handling logic does not get invented under pressure.
- Complete and structurally valid: accept and store, with the verdict attached to the record.
- No sentinel, partial body: discard the partial text and retry only if the operation is safe to repeat. Use the same request key so a duplicate cannot be silently double-applied.
- Finish reason outside the allow-list: do not retry blindly; surface the case, because repeating a length-capped call usually reproduces the same cap.
- Unparsable fragment: fail the whole stream. A partially parsed answer mixed with guessed content is worse than no answer.
Where a free model endpoint and a free server fit in this workflow
The verification above is client-side, which makes it portable: you can exercise it against any streaming endpoint without changing the contract. A free model access tier combined with a free server option is a reasonable place to build and rehearse these checks, because the artifact you are producing is a validator and a log format, not a production capacity plan.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the limitations in view when you do this. Availability of free tiers is operator-supplied and can change without notice, so nothing here should sit on a critical path with uptime expectations. Free environments may also differ from paid ones in ways you cannot see from the client, which is precisely why the contract verifies wire-level signals rather than assuming behavior.
Who should not use this approach
- Teams whose stream format is already validated by an SDK with documented termination guarantees; duplicating the check adds noise.
- Workflows where a truncated answer is harmless and cheap to regenerate, such as exploratory drafts a human will read anyway.
- Systems that need exactly-once side effects: a client-side completeness check does not replace server-side idempotency or transactional boundaries.
Start by picking one call site, write down the four acceptance conditions, and confirm that a deliberately broken stub actually trips your validator. If the validator cannot fail on demand, it is not yet evidence of anything.
Top comments (0)