Skip to content

Does o1 Support a System Message? What the Docs Actually Say

8 min read · updated August 11, 2026

You moved a working prompt from gpt-4o to an o-series model, changed nothing else, and the request came back 400 with a complaint about messages[0].role. The message is accurate and slightly misleading at the same time: the role is not unsupported everywhere in the o-series, only on the snapshots that predate the replacement for it.

The error you got

The response body is an ordinary OpenAI error object, and each of its four fields is doing work:

HTTP/1.1 400 Bad Request

{
  "error": {
    "message": "Unsupported value: 'messages[0].role' does not support 'system' with this model.",
    "type": "invalid_request_error",
    "param": "messages[0].role",
    "code": "unsupported_value"
  }
}

code is unsupported_value rather than invalid_value, and the distinction is the whole diagnosis. Invalid would mean you sent something that is not a role at all. Unsupported means the value is a perfectly good role that this particular model does not take. Nothing about your JSON is malformed, so no amount of re-reading it will help.

param carries the index of the offending message. If your system message is not first — some frameworks put a memory summary ahead of it — the string reads messages[3].role instead, which is worth knowing before you go looking at the wrong element of the array. Match on the code and the substring .role, never on the whole message; the prose in message is not a stable API.

Which models accept which role

Three groups, and the boundaries are per-snapshot rather than per-family — which is exactly why moving between two models that both call themselves o1 can change the answer.

  • The GPT familygpt-4o, gpt-4o-mini, gpt-4-turbo and the rest take a system message, as they always have. Newer ones also accept developer.
  • The first o-series preview snapshots o1-preview and o1-mini, the models released in September 2024 — accept neither system nor developer. There is no role available above user at all. This is the case that produces the error above and it cannot be fixed by renaming the role.
  • o1 general availability and everything after it — the December 2024 o1 snapshot onward — accept developer, which is the role that took over the job. OpenAI documents system as being treated as developer on these models rather than rejected, so the same payload can work on one o-series snapshot and 400 on an older one.
Role support is a per-snapshot fact and o-series snapshots have changed it more than once. Check the model page in OpenAI’s model reference for the exact id you are sending before assuming a family-wide rule.

What the developer role is

It is not a new capability. It is a rename that makes the hierarchy honest. When the API was designed, “system” meant the instruction from whoever built the application, as distinct from the end user typing into it. As models were trained to follow an explicit chain of command, that layer needed a name that says who it belongs to, and “system” had come to sound like the platform itself. Hence developer: the instructions from the application author, which the model is trained to weight above user turns and below the platform’s own policy.

The practical consequence is that on o-series models a developer message is an instruction with priority, not an inviolable rule. A reasoning model spends tokens deciding how to apply it. If you need something enforced rather than requested — a maximum length, a schema, a refusal — enforce it in the request shape or in your own code, not in the prose of a developer message. Structured Outputs exists for exactly that reason; see strict JSON schema end to end.

Two mechanical details the rename does not change. The elevated message goes first in the array; putting a user turn ahead of it is accepted by the API and weakens the instruction, because position carries priority alongside role. And nothing stops you sending several developer messages, but there is rarely a reason to — a second one further down the conversation reads as an interjection rather than as a standing instruction, and a rule meant to apply to the whole exchange belongs in the first message, where it also sits inside the cacheable prefix.

The rewrite

There are two fixes and only one of them is safe on the preview snapshots. Renaming system to developer works everywhere from the o1 GA snapshot onward. On o1-preview and o1-mini there is no elevated role at all, so the instruction has to be folded into the first user turn.

// One place that knows what each model will accept.
const NO_ELEVATED_ROLE = new Set(["o1-preview", "o1-mini"]);
const WANTS_DEVELOPER = (id) => /^(o\d|gpt-5)/.test(id);

function normaliseMessages(model, messages) {
  const head = messages[0];
  if (!head || head.role !== "system") return messages;

  if (NO_ELEVATED_ROLE.has(model)) {
    // Fold into the first user turn. Do not drop it.
    const rest = messages.slice(1);
    const firstUser = rest.findIndex((m) => m.role === "user");
    if (firstUser === -1) return [{ role: "user", content: head.content }, ...rest];
    const merged = {
      role: "user",
      content: head.content + "\n\n" + rest[firstUser].content,
    };
    return [...rest.slice(0, firstUser), merged, ...rest.slice(firstUser + 1)];
  }

  if (WANTS_DEVELOPER(model)) {
    return [{ role: "developer", content: head.content }, ...messages.slice(1)];
  }
  return messages;
}

The one thing not to do is delete the message. It is a tempting one-line fix because the request starts succeeding immediately, and it silently removes every constraint you had placed on the output — format, tone, refusal policy, the lot. The failure that follows is a quality regression with no error attached to it, which is far more expensive to find than a 400.

The other parameters o-series rejects

The role is usually the first wall you hit and rarely the last. The same unsupported_value and unsupported_parameter shape comes back for several sampling controls that reasoning models do not expose, because the sampler is not the part doing the work:

  • temperature, top_p, presence_penalty and frequency_penalty are not accepted on the early o-series snapshots. Sending the default value is still sending the parameter.
  • max_tokens is replaced by max_completion_tokens, because the old name could not express a budget that includes reasoning tokens you never see. Budgeting for those is its own subject — how reasoning tokens are billed.
  • Streaming, function calling and logprobs arrived on the o-series later than the models themselves, so an old snapshot can reject a feature the family is documented as having.

The pattern behind all of these is the same: an o-series id is not a drop-in for a GPT id, and the request body is part of what you are porting. Build the per-model normalisation once, at the edge of your code, rather than scattering conditionals through the call sites.

The second error shape is worth recognising too, because it names a different field and a different code from the role rejection:

{
  "error": {
    "message": "Unsupported parameter: 'temperature' is not supported with this model.",
    "type": "invalid_request_error",
    "param": "temperature",
    "code": "unsupported_parameter"
  }
}

Note that many SDKs and frameworks send a default temperature whether you set one or not, so this can fire on a request whose code mentions no sampling parameter anywhere. If the error names a parameter you did not write, log the serialised request body before blaming the model.

Finding out what a snapshot accepts

The documentation is authoritative and the model pages are the right place to start, but a one-token probe is faster than reading and cannot be out of date. Send the smallest possible request with the feature under test and look at whether it 400s:

for role in system developer; do
  echo -n "$role: "
  curl -s https://api.openai.com/v1/chat/completions \
    -H "Authorization: Bearer $OPENAI_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"model\":\"$MODEL\",\"max_completion_tokens\":1,
         \"messages\":[{\"role\":\"$role\",\"content\":\"x\"},
                       {\"role\":\"user\",\"content\":\"x\"}]}" \
    | jq -r '.error.code // "accepted"'
done

Three notes on running this. Use max_completion_tokens rather than max_tokens, or you will be testing two things at once and reading the wrong rejection. Set the model id to the exact dated snapshot rather than the alias, since that is the level at which the answer varies. And put the probe in your test suite against every model id in your configuration, not in a terminal you run once: this is precisely the kind of fact that is true when you check it and false after the next model you add.