400 invalid_request_error: malformed JSON, missing fields, and go2llm's invalid_request
A 400 means the server read your request and found a problem with its shape before it ever considered calling a model — the body isn't JSON, isn't an object, or is missing something required. OpenAI's API calls this invalid_request_error; go2llm calls it invalid_request. Either way it's a shape error, not a money, key or model problem: nothing is reserved and nothing is charged, because inference never starts.
Written 2026-09-28.
What the error looks like
On /v1/messages and /v1/chat/completions, a body that doesn't parse as JSON at all doesn't get the usual error envelope — it's a bare string in the error field:
{
"error": "invalid json"
}
If the body parses fine as JSON but isn't a JSON object — a bare array, a top-level string — you do get an envelope with type and message, but a shorter one than usual: no code, no request_id:
{
"error": {
"type": "invalid_request",
"message": "request body must be a JSON object"
}
}
OpenAI's own API answers a missing required field in a similarly-intentioned but differently-shaped envelope:
{
"error": {
"message": "'messages' is a required property",
"type": "invalid_request_error",
"param": "messages",
"code": null
}
}
Notice OpenAI often leaves code empty for this class and puts the substance in message plus param — which field it didn't like. go2llm's invalid_request doesn't carry a param field; the field name, when it matters, is written into message instead.
A separate situation is a well-formed request that the *model provider itself* refuses — a parameter value it rejects, a safety refusal, content it won't process. That still reaches you as a 400, but it isn't one of go2llm's own six request-shape codes below, since the gateway's own checks already passed. In that case error.message is go2llm's own generic wording, not the vendor's — the vendor's own words, when the response carries them at all, land in a separate error.upstream_message field instead, and error.code won't tell you which situation you're in.
Causes, most common first
1. A required field is missing — but go2llm itself only checks one of them
POST /v1/messages needs model, max_tokens and messages, per Anthropic's own Messages API contract; POST /v1/chat/completions needs model and messages, per OpenAI's Chat Completions contract — max_tokens is optional there. go2llm itself only validates model before doing anything else: a missing or unrecognized one fails the catalog lookup and comes back as 404 model_not_found (see 404 model_not_found), not this page's invalid_request. A missing messages or max_tokens isn't checked here at all — the body is forwarded to the model provider as written, and whatever status and shape come back are what you see, not a code go2llm defines itself.
2. The body isn't valid JSON
An unclosed quote, a trailing comma after the last array element, or a body built by string concatenation without escaping. On /v1/messages and /v1/chat/completions — this page's own endpoints — that's the bare-string envelope shown above, {"error": "invalid json"}, every time, regardless of the exact syntax mistake. The wording request body must be valid JSON is real, but it belongs to other endpoints — /v1/responses, /v1/messages/count_tokens, /v1/images/generations — which wrap the same failure in go2llm's usual error.type/error.message envelope instead.
3. The body parses but isn't a JSON object
A bare array of messages sent instead of an object with a messages field, or a plain string where an object was expected — often the result of serializing the wrong layer of a data structure. Message: request body must be a JSON object.
4. A field has the wrong type — and the provider is the one who says so
messages sent as a string instead of an array, max_tokens sent as a string instead of a number, temperature outside the accepted range. go2llm doesn't type-check any of these before forwarding, so the rejection is the provider's, not the gateway's: expect the provider's own status and wording, relayed with a code like upstream_http_400 and the vendor's text in error.upstream_message when it survives. Don't count on this arriving as go2llm's own invalid_request.
5. A parameter copied from the wrong endpoint or protocol
A common source: example code copied from the docs for a neighboring endpoint or protocol. previous_response_id, store and background are /v1/responses-only fields and produce their own, more specific codes there — 400 stateful_responses_unsupported and background_unsupported, not the generic invalid_request. In the same spirit, an image or PDF block inside messages produces unsupported_content_type, not invalid_request — see 400 unsupported_content_type.
Fixing it, step by step
Step 1. Validate that the body is well-formed JSON before it leaves your machine, not after the error comes back:
cat request-body.json | python3 -m json.tool > /dev/null && echo "valid JSON"
Step 2. Check the required fields for the endpoint you're calling:
def assert_required_fields(body: dict, endpoint: str) -> None:
required = {
"messages": ("model", "max_tokens", "messages"),
"chat.completions": ("model", "messages"),
}[endpoint]
missing = [f for f in required if f not in body]
if missing:
raise ValueError(f"body is missing: {missing}")
Step 3. If a 400 still comes back, read the whole error. There are three places to look and they are not interchangeable: malformed JSON makes error a bare string, invalid json; go2llm's own request-shape checks spell the problem out in error.message; and when the model itself rejected the request, error.message is a generic gateway line and the vendor's own wording, if it survived, sits in error.upstream_message. The command below handles all three:
curl -s https://go2llm.tech/v1/chat/completions \
-H "Authorization: Bearer $GO2LLM_API_KEY" \
-H "content-type: application/json" \
-d @request-body.json | python3 -c "import sys,json; e=json.load(sys.stdin)['error']; print(e if isinstance(e, str) else (e.get('upstream_message') or e.get('message')))"
Step 4. A known-good minimal body for each protocol, to diff your own request against:
curl https://go2llm.tech/v1/messages \
-H "x-api-key: $GO2LLM_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5","max_tokens":120,"messages":[{"role":"user","content":"Reply with one word: working"}]}'
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.GO2LLM_API_KEY,
baseURL: "https://go2llm.tech/v1",
});
const response = await client.chat.completions.create({
model: "gpt-5.6-luna",
messages: [{ role: "user", content: "Reply with: OK" }],
});
console.log(response.choices[0].message.content);
Both bodies carry exactly what their own protocol needs — max_tokens is required on Anthropic Messages and optional on OpenAI Chat Completions, which is itself a frequent source of a 400 when code is ported from one protocol to the other without adjusting for it.
Avoiding it next time
Validate the body against a schema on your own side rather than treating the server's answer as the only check — this matters more, not less, when a library or framework assembles the request for you, since some of them add vendor-specific fields the other vendor's API will not accept. Keep separate, minimal request templates for /v1/messages and /v1/chat/completions instead of one shared body builder: the two protocols disagree on which fields are required, and a shared builder eventually drops one of them for one side.
There's no point retrying a 400. go2llm returns the same answer until the body itself changes, and a retry loop just burns your 20 RPM per-key ceiling for nothing.
Related errors
- 400 unsupported_content_type — a non-text block inside a text request, its own code rather than the generic
invalid_request. - 400 stateful_responses_unsupported —
previous_response_idandstoreon/v1/responses. - 404 model_not_found — the body is fine, but the model isn't in the catalog.
- context_length_exceeded and maximum context length exceeded — a well-formed request that's simply too big for the model.
- The full error reference.
FAQ
Is go2llm's invalid_request the same thing as OpenAI's invalid_request_error? Same category — the server parsed the body and found a shape problem — under a different literal name. go2llm's is shorter, invalid_request, and the envelope around it is go2llm's own rather than OpenAI's.
Can I tell the exact cause from the HTTP status alone? No. Several different codes share status 400 on go2llm: invalid_request, unsupported_content_type, model_protocol_mismatch, stateful_responses_unsupported, background_unsupported, idempotency_key_required. Branch on error.code, never on the status alone.
Does a 400 cost anything? No. The body is checked before any model is called, so no reservation is ever made and nothing is ever settled.
Why doesn't the error message always name the exact field? For the basic JSON checks — is it parseable, is it an object — go2llm only states the nature of the problem, and the exact wording depends on the endpoint: /v1/messages and /v1/chat/completions answer unparseable JSON with the bare string invalid json and a non-object body with request body must be a JSON object; /v1/responses, /v1/messages/count_tokens and /v1/images/generations wrap unparseable JSON as request body must be valid JSON instead. Checks with more specific rules, like the content-type validator, already say which field and where.
Get a key and make your first request in 3 minutes → go2llm.tech. Per-model prices are on the pricing page, setup steps are in the docs.