When you need to test sse stream curl llm api endpoints, curl is the fastest way to see raw bytes without an SDK masking behavior. Streaming Server-Sent Events from LLM APIs have quirks—chunked encoding, partial JSON, and keep-alive comments—that a quick Python script hides.
Prerequisites
curl7.68 or newer (supports--no-bufferand HTTP/2 cleanly)jq1.6+ for JSON parsingpython3for any custom post-processing- A valid API key for an OpenAI-compatible endpoint
- Basic comfort with bash pipes
If you are hitting a gateway, the same patterns apply. The examples below use the standard /v1/chat/completions shape.
The minimal streaming request
A streaming chat completion requires stream: true and the Accept: text/event-stream header. Curl must disable buffering with -N or --no-buffer, otherwise it will wait for the full response.
curl -N https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"model": "gpt-3.5-turbo",
"stream": true,
"messages": [{"role": "user", "content": "Say hello in 5 words."}]
}'
Expected raw output (truncated):
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}
data: [DONE]
Note the blank lines between data: frames. That is part of the SSE spec: each event is terminated by a double newline.
Understanding the raw SSE frame
SSE frames can carry event:, data:, id:, and retry: lines. LLM APIs almost always send only data: with a JSON payload. Lines starting with a colon are comments used as keep-alives:
: ping
If you see : ping every 15–30 seconds, the server is keeping the TCP connection alive. Curl prints these to stdout just like data lines. To filter them:
curl -N ... | grep '^data:'
But the [DONE] sentinel is also prefixed with data:. Strip the prefix and drop the sentinel for clean JSON:
curl -N ... | sed 's/^data: //' | grep -v '^\[DONE\]'
Extracting tokens with a one-liner
To watch just the generated text, parse each JSON line and pull choices[0].delta.content. jq will choke on the [DONE] line, so filter first.
curl -N https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"model":"gpt-3.5-turbo","stream":true,"messages":[{"role":"user","content":"Count to 3."}]}' \
| sed 's/^data: //' \
| grep -v '^\[DONE\]' \
| jq -r '.choices[0].delta.content // empty'
Output:
One
Two
Three
If your model returns usage info in the final chunk (common in newer OpenAI-compatible servers), the last JSON line has a usage field and no delta.content. The // empty guard avoids null prints.
Inspecting headers and connection behavior
Streaming bugs often live in headers. Use -v to see the handshake:
curl -N -v https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"model":"gpt-3.5-turbo","stream":true,"messages":[{"role":"user","content":"Hi"}]}' 2>&1 | head -20
Look for:
< HTTP/2 200
< content-type: text/event-stream
< transfer-encoding: chunked
< cache-control: no-cache
If you get content-encoding: gzip with a streaming response, some proxies misbehave. Curl decompresses automatically, but intermediate layers may buffer. Force uncompressed with --compressed only if you control both ends; otherwise ask the gateway to disable compression for streams.
Measuring time-to-first-token
A common SLO is latency to first byte. Curl’s -w does not capture streaming intervals well, but you can timestamp each line:
curl -N ... | while IFS= read -r line; do
if [[ "$line" == data:* ]]; then
echo "$(date +%s.%N) $line"
fi
done | head -3
For a proper TTFB, start a timer before curl and print on first data: that contains content:
start=$(date +%s.%N)
curl -N ... | while IFS= read -r line; do
if [[ "$line" == data:* ]] && [[ "$line" == *"\"content\""* ]]; then
echo "first token at $(date +%s.%N), delta $(echo "$(date +%s.%N) - $start" | bc)"
break
fi
done
Simulating client disconnects
You want to know if the server stops generating when the client hangs up. Pipe to head -c to truncate early:
curl -N ... | head -c 200
Curl receives SIGPIPE when head exits; the connection closes. Watch server logs for cancelled requests. If you are behind a load balancer, confirm it propagates the RST and does not keep spawning tokens.
Testing routing and fallback through a gateway
When you test sse stream curl llm api against a gateway such as n4n.ai, which exposes one OpenAI-compatible endpoint across 240+ models with automatic fallback when a provider is degraded, you can assert routing with a custom header and inspect the response headers for which backend served the stream. Send a client routing directive and watch the x-routed-provider (or similar) header:
curl -N -D - https://api.n4n.ai/v1/chat/completions \
-H "Authorization: Bearer $N4N_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "x-n4n-route: provider=anthropic" \
-d '{"model":"claude-3-haiku","stream":true,"messages":[{"role":"user","content":"ping"}]}' \
| grep -i 'x-routed-provider\|^data:'
If the primary provider is rate-limited, the gateway should fail over before or at stream start; the header tells you which backend actually responded. This is impossible to verify through a high-level SDK that swallows headers.
Common pitfalls
Buffering in intermediate shells. python -u or stdbuf -oL may be needed if you wrap curl in a script.
JSON escaping in -d. Use a heredoc or @file.json to avoid shell mangling:
cat > req.json <<'EOF'
{"model":"gpt-3.5-turbo","stream":true,"messages":[{"role":"user","content":"Hello"}]}
EOF
curl -N https://api.openai.com/v1/chat/completions -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "Accept: text/event-stream" -d @req.json
Missing Accept header. Some gateways return a single JSON blob if they think you are not streaming.
TLS session reuse. HTTP/2 connection coalescing can mask per-request routing. Use --http1.1 if you need isolated connections.
Keep-alive comments breaking parsers. Always skip lines not starting with data:.
A reusable debug function
Drop this in your .bashrc to standardize how you test sse stream curl llm api calls:
llm-stream() {
local url="${1:-https://api.openai.com/v1/chat/completions}"
local key="${2:-$OPENAI_API_KEY}"
local body="${3:-{\"model\":\"gpt-3.5-turbo\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"Say hi\"}]}}"
curl -N "$url" \
-H "Authorization: Bearer $key" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d "$body" \
| sed 's/^data: //' \
| grep -v '^\[DONE\]' \
| jq -r '.choices[0].delta.content // empty'
}
Call it with llm-stream https://api.n4n.ai/v1/chat/completions "$N4N_KEY" '@req.json'. You get a clean token feed and can swap endpoints to compare providers.
Verifying cache-control hints
OpenAI-compatible servers may emit cache-control or accept x-cache-key. When you forward provider cache-control hints through a gateway, confirm the header survives. Use -D - and grep:
curl -N -D - ... | grep -i 'cache-control\|x-cache'
If the stream response carries cache-control: no-transform, your CDN should not buffer it.
Final checklist
Before you trust a streaming integration, run these three commands:
curl -N -vto confirmtext/event-streamand chunked encoding.curl -N | sed | jqto confirm parseable JSON and clean token extraction.curl -N -D -with routing header to confirm backend selection and fallback behavior.
That is the entire surface area for debugging most LLM streaming issues without writing a line of application code.