curl Recipes for LLM APIs: Debug Before You Write Code
Guides

curl Recipes for LLM APIs: Debug Before You Write Code

A working set of curl commands for OpenAI-compatible endpoints: streaming, timing, tool calls, error bodies, and building JSON safely with jq.

When an SDK misbehaves, the fastest way to find out whether the problem is yours or the provider's is to take the SDK out of the picture. curl is the ground truth: no retry layer, no client-side parsing, no framework middleware, just the bytes on the wire.

These are the commands worth having in your shell history. They assume an OpenAI-compatible endpoint and two environment variables, LLM_BASE_URL ending at /v1 and LLM_API_KEY.

The baseline call

curl -sS --fail-with-body "$LLM_BASE_URL/chat/completions" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kimi-k3",
    "messages": [{"role": "user", "content": "Reply with the word OK."}]
  }' | jq .

Three flags earn their place. -sS hides the progress meter but keeps errors visible, which is what you want in a script. --fail-with-body makes curl exit non-zero on an HTTP 4xx or 5xx while still printing the response — plain curl exits zero on a 500, which is how CI stages pass on failed calls. And piping to jq gives you readable JSON.

Run this first whenever anything is broken. If it works, your credentials and endpoint are fine and the problem lives in your application. If it returns 404, check for a doubled path segment before suspecting the key: /v1/v1/chat/completions is the single most common setup error and its error message rarely says so.

Build the body with jq, not string concatenation

The moment your prompt contains a quote, a newline or a backslash, hand-assembled JSON breaks. Let a tool do the escaping:

jq -n --rawfile prompt ./prompt.txt --arg model "kimi-k3" '{
  model: $model,
  temperature: 0,
  max_tokens: 500,
  messages: [
    {role: "system", content: "You are terse."},
    {role: "user", content: $prompt}
  ]
}' > /tmp/req.json

curl -sS --fail-with-body "$LLM_BASE_URL/chat/completions" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d @/tmp/req.json | jq -r '.choices[0].message.content'

The @file form for -d also avoids argument length limits, which you will hit sooner than you expect once you start pasting a diff or a stack trace into a prompt. And a file on disk is reusable: the same request body can be replayed against a second provider without retyping anything.

Keep a directory of these fixture bodies. They become the fastest possible smoke test when you change endpoints, and they are the seed of the golden set described in migrating off the OpenAI API.

Streaming, and why -N matters

curl -N -sS "$LLM_BASE_URL/chat/completions" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kimi-k3",
    "stream": true,
    "messages": [{"role": "user", "content": "Count slowly to twenty."}]
  }'

The -N flag disables curl's own output buffering. Without it curl may hold output and you will conclude the server is buffering when it is not. This is the single most useful diagnostic in the whole set: if tokens trickle here but arrive all at once through your application, the buffering is on your side — a proxy, compression middleware, or a runtime that collects the full response. The catalogue of causes is in streaming and server-sent events.

To read the stream as text rather than raw event lines, extract the deltas:

... | grep --line-buffered '^data: ' \
    | sed -u 's/^data: //' \
    | grep -v '^\[DONE\]$' \
    | jq -j --unbuffered '.choices[0].delta.content // empty'

The [DONE] sentinel is not valid JSON, so filtering it out before jq avoids a parse error at the end of every otherwise successful stream. The // empty handles chunks that carry only a role or a finish reason.

Measure latency properly

Total time tells you little. What matters for anything interactive is how long before the first byte of generated output arrives:

curl -N -sS -o /dev/null \
  -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
  "$LLM_BASE_URL/chat/completions" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d @/tmp/req.json

The breakdown separates network from inference. A high time_connect or time_appconnect is geography and TLS, not the model; a high time_starttransfer with a fast connect is prompt processing. That distinction decides whether the fix is a closer region or a shorter prompt, and the mechanics are covered in how inference latency breaks down.

Run it several times. A single measurement against a shared endpoint tells you about that moment, not about the service, and variance between calls is often larger than the difference between two providers.

See the headers and the error body

curl -sS -D /tmp/headers.txt -o /tmp/body.json \
  -w '%{http_code}\n' \
  "$LLM_BASE_URL/chat/completions" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d @/tmp/req.json

grep -i -E 'ratelimit|retry-after|request-id' /tmp/headers.txt
jq . /tmp/body.json

Rate limit headers are where you find out how much headroom you actually have, and the names vary between providers — grepping case-insensitively for a substring is more reliable than guessing the exact header. A request ID, where one is returned, is what a provider's support will ask for, so capture it on any failure worth reporting.

Read the error body rather than the status alone. A 400 that says the context length was exceeded and a 400 that says a parameter is unknown are entirely different problems with the same status code. Debugging LLM API errors goes through the ambiguous ones.

Tool calls and other request shapes

jq -n '{
  model: "kimi-k3",
  messages: [{role: "user", content: "What is the weather in Phnom Penh?"}],
  tools: [{
    type: "function",
    function: {
      name: "get_weather",
      description: "Current weather for a city",
      parameters: {
        type: "object",
        properties: {city: {type: "string"}},
        required: ["city"]
      }
    }
  }]
}' > /tmp/tools.json

Post that and inspect .choices[0].message.tool_calls. If the array is present with a parseable arguments string, the endpoint honours tool calling. If the model instead answers in prose about the weather it cannot look up, tool calls are not being negotiated and no amount of SDK configuration will fix it. This ten-second check saves a day of debugging an agent that never calls anything — see how tool calling works for what should come back.

The same technique applies to any feature you are unsure about. Send it, inspect the response, and remember that unknown parameters are usually ignored rather than rejected — so a request that succeeds is not proof the parameter took effect.

Habits worth keeping

Never put the key on the command line as a literal; process arguments are visible to other users on the machine and land in your shell history. Export it, or read it from a file with -H "Authorization: Bearer $(cat ~/.llm_key)".

Save every unusual response body to a file rather than reading it off the terminal. The one you skimmed and closed is always the one you needed twenty minutes later.

And keep --fail-with-body in any curl that runs unattended. It converts a silent wrong answer into a loud failure, which is the whole point of putting curl in a script rather than an SDK.

Common questions

Why does my curl streaming test not show tokens arriving one by one?

Add -N to disable curl's own output buffering. Without it curl can hold output and make a perfectly good stream look like it arrives in one block, sending you off to debug a server that is behaving correctly.

Why does my script succeed when the API returned an error?

Plain curl exits zero on HTTP 4xx and 5xx. Use --fail-with-body so a non-2xx status becomes a non-zero exit code while the error body still prints, which is what you need to diagnose it.

How do I put a large diff or file into the prompt safely?

Build the JSON with jq -n --rawfile and post it with -d @file. Hand-assembled JSON breaks on the first quote or backslash in the content, and the file form also avoids command-line length limits.

Similar articles

Aider Setup Guide: Any OpenAI-Compatible Endpoint
Guides
Guides·8 min read

Aider Setup Guide: Any OpenAI-Compatible Endpoint

Configure Aider against a custom base URL — the openai/ prefix, .aider.conf.yml, model metadata for unknown models, and picking the right edit format.

Read
Cline Setup Guide: Pointing It at a Custom Endpoint
Guides
Guides·8 min read

Cline Setup Guide: Pointing It at a Custom Endpoint

Configure Cline against any OpenAI-compatible base URL — the provider fields, the model configuration block that people skip, and separate Plan and Act models.

Read
Continue.dev Setup: config.yaml for a Custom Endpoint
Guides
Guides·8 min read

Continue.dev Setup: config.yaml for a Custom Endpoint

Point Continue at any OpenAI-compatible base URL using config.yaml — model roles, apiBase, request options, and why the autocomplete slot needs its own model.

Read