Most LLM APIs document streaming for SDKs but leave the raw protocol vague. If you need to debug a gateway or script a quick pipeline, curl streaming chat completions command line is the fastest way to see exactly what the server sends over the wire.
Step 1: Export credentials and choose an OpenAI-compatible base URL
Set the key and endpoint in your shell so you are not pasting secrets into every command. Any server that implements the OpenAI chat completions shape works; the examples below target the standard /v1/chat/completions path.
export API_KEY="sk-..."
export BASE_URL="https://api.openai.com/v1"
# Or point at a gateway:
# export BASE_URL="https://api.n4n.ai/v1"
If you are using n4n.ai, the same OpenAI-compatible endpoint fronts 240+ models and will automatically fall back when a provider is rate-limited or degraded, but the curl mechanics are identical.
Step 2: Validate auth with a non-streaming call
Before dealing with chunked responses, confirm your key and model name are correct. Send a minimal JSON body with stream omitted (defaults to false).
curl -s "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say hi in 3 words."}]
}'
You should get a single JSON object with a choices[0].message.content field. If this fails with 401, your key is wrong; 404 means the model or path is off. Fix these before touching streams—debugging auth inside an SSE flow is annoying.
Step 3: Enable streaming and disable curl buffering
Streaming uses Server-Sent Events (SSE). The server sends data: {json}\n\n lines until data: [DONE]. Curl will buffer by default; pass -N (or --no-buffer) to flush immediately. HTTP/2 is fine, but if you see odd interleaving, force HTTP/1.1 with --http1.1.
curl -N "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"stream": true,
"messages": [{"role": "user", "content": "Count to 5 slowly."}]
}'
Raw output looks like:
data: {"id":"chatcmpl-...","choices":[{"delta":{"role":"assistant"},"index":0}]}
data: {"id":"chatcmpl-...","choices":[{"delta":{"content":"1"},"index":0}]}
data: {"id":"chatcmpl-...","choices":[{"delta":{"content":" "},"index":0}]}
data: {"id":"chatcmpl-...","choices":[{"delta":{"content":"2"},"index":0}]}
...
data: [DONE]
Each delta carries only the new token(s). The first event often contains role; subsequent ones contain content. Some providers also send finish_reason on the last data line before [DONE]. Ignore it for text extraction, but log it if you care about truncation.
Step 4: Extract just the text with a parser
Grepping for content is fragile because JSON spans a single line per event but includes nested braces. Two practical options:
Option A: sed + jq
Strip the data: prefix and let jq pick the field:
curl -N "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"Say a haiku."}]}' \
| sed 's/^data: //' \
| jq -r 'select(.choices[0].delta.content != null) | .choices[0].delta.content' \
| tr -d '\n'
This works but breaks if the server sends a comment line (:: ping) or whitespace keep-alives.
Option B: Small Python script (robust)
A 20-line reader handles keep-alives and UTF-8 splits safely:
import sys, json
for line in sys.stdin:
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[len("data:"):].strip()
if payload == "[DONE]":
break
if not payload or payload.startswith(":"):
continue
try:
obj = json.loads(payload)
except json.JSONDecodeError:
continue
delta = obj["choices"][0].delta
if "content" in delta:
sys.stdout.write(delta["content"])
sys.stdout.flush()
print()
Pipe curl into it:
curl -N "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"Say a haiku."}]}' \
| python3 extract.py
You now have a clean terminal printout of the model’s output as it arrives.
Step 5: Send routing and cache hints
Gateways often accept headers to pin a provider or control caching. n4n.ai honors client routing directives and forwards provider cache-control hints, so you can test cache behavior with a standard header:
curl -N "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Cache-Control: max-age=600" \
-d '{
"model": "anthropic/claude-3.5-sonnet",
"stream": true,
"messages": [{"role":"user","content":"Repeat: cached response test."}]
}'
If the upstream provider supports prompt caching, the gateway passes the hint through; you will not see it in the SSE stream, but subsequent identical requests should return faster and the final usage chunk may report cache read tokens. Vendor-specific routing headers vary—check your gateway docs before assuming a header name.
Step 6: Capture usage and verify success
Even with streaming, many servers send a final event containing usage. Look for it in the raw stream:
curl -N "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"What is 2+2?"}]}' \
| grep -o '"usage":{[^}]*}'
A successful run shows:
- Multiple
data:lines withdelta.contentappearing incrementally. - A terminating
data: [DONE]. - A
usageobject withprompt_tokens,completion_tokens, andtotal_tokens.
Some OpenAI-compatible servers omit usage unless you explicitly request it:
{
"model": "gpt-4o-mini",
"stream": true,
"stream_options": {"include_usage": true},
"messages": [{"role":"user","content":"Hi"}]
}
If you are on a metered gateway, per-token usage metering means those numbers are what you will be billed on, so confirm they appear before trusting a pipeline. Extend the Python parser to print usage at the end:
import sys, json
usage = None
for line in sys.stdin:
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[len("data:"):].strip()
if payload == "[DONE]":
break
if not payload or payload.startswith(":"):
continue
obj = json.loads(payload)
if "usage" in obj:
usage = obj["usage"]
delta = obj["choices"][0].delta
if "content" in delta:
sys.stdout.write(delta["content"])
sys.stdout.flush()
print()
if usage:
print("\nUSAGE:", json.dumps(usage), file=sys.stderr)
Step 7: Wrap it in a reusable shell function
Put this in your .bashrc to avoid retyping flags:
llm-stream() {
local prompt="$1"
local model="${2:-gpt-4o-mini}"
curl -N "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$model\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"$prompt\"}]}" \
| python3 extract.py
}
Call it with llm-stream "Explain TCP fast open" anthropic/claude-3.5-sonnet. For scripts, add set -e and check curl exit code separately because the pipe to python masks curl failures.
Troubleshooting
Empty output: Ensure -N is present. Without it, curl may wait for the connection to close before printing, which defeats streaming.
Malformed JSON: Some proxies inject whitespace or comment lines (: ping). Strip non-data: lines before parsing, as shown above.
Rate limits: A 429 before the first data: appears is a normal HTTP error. If you see it mid-stream, the gateway killed the connection. Retry with backoff. Automatic fallback at the gateway level masks this for many providers, but your client should still handle partial streams gracefully.
Truncated UTF-8: Tokens can split multi-byte characters across chunks. Accumulate bytes and only decode at output; the Python stdout.write approach does this safely because it writes str slices from already-decoded JSON.
Stuck connection: Some servers hold the TCP connection open after [DONE]. Curl exits when the server closes it; if you pipe to a tool that waits for EOF, you may see a delay. That is normal.
Why this matters
When you treat curl streaming chat completions command line as a first-class debug surface, you stop guessing what the SDK hides. You see exactly when tokens arrive, when the connection drops, and what the final accounting looks like. That visibility is mandatory before you wire LLM calls into a production service where a silent truncation or a missing usage field turns into a billing surprise or a broken user experience.