DEV Community

Aman Kumar
Aman Kumar

Posted on

Calling an OpenAI-Compatible API From a Bash Script: curl, jq, Safe JSON and What --retry Really Does

I'm Aman Kumar. A lot of LLM glue code never needs Python or Node. A cron job that summarizes a log, a git hook that writes a commit message, a CI step that labels an issue: a short Bash script with curl and jq does the job, and it works against any OpenAI-compatible server (vLLM, LiteLLM, Ollama's /v1 endpoint, or a hosted gateway).

The trouble is that curl's defaults were designed for fetching URLs, not for calling a JSON API, so the script that works on the first try tends to fail quietly later. I tested everything below with curl 8.14.1, jq 1.7 and Bash 5.2 against a local mock server that implements /v1/chat/completions and /v1/models and can return 401, 400, 429 (with Retry-After), 503 and slow responses on demand. The examples point at APIClaw, an OpenAI-compatible gateway I build, so weigh my opinions accordingly. The base URL is https://apiclaw.biz/v1. I use YOUR_MODEL_ID below; get real IDs from /v1/models or your provider's model list.

Problem 1: building the JSON body with string interpolation

This is the version most people write first:

curl -s "$BASE_URL/chat/completions" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"$PROMPT\"}]}"
Enter fullscreen mode Exit fullscreen mode

It works until the prompt contains a double quote, a backslash or a newline, which is to say the first time you pipe in a real log file or a diff. My mock answered with 400 Invalid JSON: Expecting ',' delimiter, and curl still exited with status 0.

Let jq build the body instead. --arg takes any string and escapes it correctly, and --rawfile does the same for a whole file:

jq -n --arg model "$MODEL" --arg prompt "$PROMPT" '{
  model: $model,
  messages: [{role: "user", content: $prompt}]
}' | curl -sS --json @- "$BASE_URL/chat/completions" ...

# or send a file as the prompt
jq -n --arg model "$MODEL" --rawfile diff changes.diff \
  '{model: $model, messages: [{role: "user", content: ("Review this diff:\n" + $diff)}]}'
Enter fullscreen mode Exit fullscreen mode

--json @- (curl 7.82 and later) reads the body from stdin, sends it unmodified, and sets both Content-Type: application/json and Accept: application/json. That matters because plain -d sends Content-Type: application/x-www-form-urlencoded. Lenient servers ignore the header, but stricter ones reject the request. -d @file also strips newlines from the file. That is harmless for JSON, but it surprises people who later reuse the same flag for other payloads. If your curl is older than 7.82, use --data-binary @- plus an explicit Content-Type header.

Problem 2: curl says "success" on a 401

By default curl exits with 0 for any HTTP response, including 401, 404 and 500. Combined with the usual jq -r '.choices[0].message.content', a bad key gives you the literal string null on stdout and exit code 0 from both commands. A cron job will happily write null into your report every night.

Here are the options I tested against a 401:

Flags Exit code What reaches stdout
-s 0 the error JSON
-sf 22 nothing, and no message either
-sSf 22 nothing; curl: (22) ... error: 401 on stderr
-sS --fail-with-body 22 the error JSON, and the message on stderr

--fail-with-body (curl 7.76 and later) is the one you want for API calls, because the error body is the only place the server tells you why it refused. On the jq side, use -e. jq -er '.choices[0].message.content' still prints null for a missing field, but it exits with 1, so set -euo pipefail actually stops the script.

Problem 3: what --retry actually retries

--retry 3 is the obvious fix for rate limits, and it does more than I expected. With my mock returning 429 and Retry-After: 3 twice before succeeding, curl waited 3 seconds between attempts, so it honours Retry-After. For a 503 without that header it backed off for 1 second, then 2. It did not retry a 400 or a 401. It also retried POST requests without complaint, so retries are not limited to safe GETs.

There are two gotchas.

Without --fail, every failed attempt's body goes to stdout. My 429 test printed three JSON objects back to back: two rate-limit errors followed by the real answer. Piped into jq, that produced null, null, and then the reply, all with exit code 0. With -f or --fail-with-body the error bodies stay out of stdout. The cleanest option is -o file: curl writes the response to a file and truncates that file before each retry, so after three attempts mine held only the final 200 body. -w '%{http_code}' then reports the final status, and %{num_retries} tells you how many retries happened.

Timeouts are retried too, and each attempt gets a fresh --max-time. With --max-time 2 --retry 2 against an endpoint that took 5 seconds, curl made three attempts and the command took about 9 seconds. --max-time caps each attempt, not the whole run; --retry-max-time caps the total. A timeout also doesn't mean the server stopped. My mock handled every one of those slow requests to the end, so on a metered API a timed-out retry can cost you twice. Set --max-time generously for long completions instead of retrying aggressively.

Problem 4: the API key in ps

-H "Authorization: Bearer $LLM_API_KEY" puts the key in curl's command line, and anyone on the machine who can run ps can read it. I checked: ps -o args= -C curl showed -H Authorization: Bearer sk-test in full. curl can read headers from a file with -H @file, and Bash process substitution turns that into a file that never touches disk:

curl -H @<(printf 'Authorization: Bearer %s\n' "$LLM_API_KEY") ...
Enter fullscreen mode Exit fullscreen mode

printf is a Bash builtin, so the key never shows up as a process argument either. ps now shows -H @/dev/fd/63. curl -K - (a config file read from stdin) also works if your shell has no process substitution.

The script I actually use

This puts it all together: jq builds the body, the key stays out of ps, there's a timeout on every attempt, retries are bounded, the response lands in a temp file so retry noise never reaches stdout, and the exit code is non-zero whenever there's no answer.

#!/usr/bin/env bash
# ask.sh: send one prompt to an OpenAI-compatible API and print the reply.
set -euo pipefail

: "${LLM_API_KEY:?set LLM_API_KEY}"
BASE_URL="${LLM_BASE_URL:-https://apiclaw.biz/v1}"
BASE_URL="${BASE_URL%/}"                 # avoid /v1//chat/completions
MODEL="${LLM_MODEL:?set LLM_MODEL to a model ID from /v1/models}"
PROMPT="${1:?usage: ask.sh \"your prompt\"}"

tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT

status=$(
  jq -n --arg model "$MODEL" --arg prompt "$PROMPT" '{
      model: $model,
      messages: [
        {role: "system", content: "Answer briefly."},
        {role: "user",   content: $prompt}
      ]
    }' |
  curl -sS --json @- \
       -H @<(printf 'Authorization: Bearer %s\n' "$LLM_API_KEY") \
       --connect-timeout 10 --max-time 120 \
       --retry 3 --retry-max-time 300 \
       -o "$tmp" -w '%{http_code}' \
       "$BASE_URL/chat/completions"
)

if [[ $status != 2* ]]; then
  echo "HTTP $status: $(jq -r '.error.message // .' "$tmp" 2>/dev/null || cat "$tmp")" >&2
  exit 1
fi

jq -er '.choices[0].message.content' "$tmp"
jq -r '"tokens: \(.usage.prompt_tokens) in, \(.usage.completion_tokens) out"' "$tmp" >&2
Enter fullscreen mode Exit fullscreen mode

Against the mock, a prompt with quotes and a newline came back intact. A bad key printed HTTP 401: Invalid API key and exited with 1. A model that returned 429 twice succeeded on the third try with clean output. An unreachable host exited with curl's own code 7. The ${BASE_URL%/} line is there because a trailing slash in the env var produced a request to /v1//chat/completions, which my mock (like many real servers) answered with a 404 that looks like a wrong model.

Multi-turn chat in a loop

Keep the history as a JSON array in a shell variable and let jq append to it. --argjson passes the array in as JSON rather than as a string:

history='[{"role":"system","content":"You are a terse shell assistant."}]'

while IFS= read -r -p '> ' line; do
  history=$(jq -c --arg c "$line" '. + [{role:"user",content:$c}]' <<<"$history")
  reply=$(
    jq -n --arg model "$LLM_MODEL" --argjson messages "$history" \
          '{model:$model, messages:$messages}' |
    curl -sS --fail-with-body --json @- \
         -H @<(printf 'Authorization: Bearer %s\n' "$LLM_API_KEY") \
         --max-time 120 "${BASE_URL%/}/chat/completions" |
    jq -er '.choices[0].message.content'
  ) || { echo "request failed" >&2; continue; }
  printf '%s\n' "$reply"
  history=$(jq -c --arg c "$reply" '. + [{role:"assistant",content:$c}]' <<<"$history")
done
Enter fullscreen mode Exit fullscreen mode

The history grows with every turn, and the whole thing is resent each time, so cap it for long sessions. A failed request doesn't add an assistant turn, so the history stays consistent.

If you want tokens as they arrive, add "stream": true to the body, run curl with -N so it doesn't buffer, and pipe it through sed -un 's/^data: //p' | grep -v --line-buffered '^\[DONE\]$' | jq --unbuffered -j '.choices[0].delta.content // empty'. That printed the mock's chunks as they arrived. To list models, run curl -sS -H @<(...) "$BASE_URL/models" | jq -r '.data[].id'.

Checklist

  1. Build request bodies with jq -n --arg, never with string interpolation.
  2. Send them with --json @- (or --data-binary plus a JSON Content-Type), not plain -d.
  3. Use --fail-with-body or -o file -w '%{http_code}', and jq -e, so failures produce a non-zero exit.
  4. Never combine --retry with plain -s output piped to jq; send the response to a file.
  5. Set --connect-timeout and --max-time, and use --retry-max-time to cap the total.
  6. Pass the key with -H @<(printf ...) so it stays out of ps.
  7. Strip the trailing slash from the base URL.

Disclosure: I build APIClaw, the OpenAI-compatible gateway in these examples (flat monthly plans; crypto on the site, other payment methods through support). Everything above applies to any compatible endpoint.

Top comments (0)