Migrating From a Chat Completions Shape to a Responses Shape
11 min read · updated August 11, 2026
Most of this migration is renaming four parameters and walking a list instead of indexing into choices[0]. The part that is not mechanical is the tool round trip, where identifiers and message roles are replaced by typed items, and a wrong guess produces a request the server rejects with no useful detail.
The call, before and after
Start with a request that has all the common pieces: a system instruction, a transcript, a token cap and a sampling parameter.
# chat completions shape
resp = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "You are a terse support agent."},
{"role": "user", "content": "Where is order 4471?"},
],
max_tokens=300,
temperature=0.2,
)
text = resp.choices[0].message.content
finish = resp.choices[0].finish_reason
used = resp.usage.completion_tokens
# responses shape
resp = client.responses.create(
model=MODEL,
instructions="You are a terse support agent.",
input=[
{"role": "user", "content": "Where is order 4471?"},
],
max_output_tokens=300,
temperature=0.2,
)
text = resp.output_text
status = resp.status
used = resp.usage.output_tokensThree things to notice before the detail. The system turn left the transcript and became a parameter. The token cap gained the word output. And the reply is read through an accessor rather than by indexing, because there is no single message to index to — the naming page explains why the object is shaped that way.
Parameter by parameter
messagestoinput. An array of role-and-content objects still works, so the transcript you already have moves across unchanged once the system entry is removed from it. A bare string is also accepted for the single-turn case.- The
systemordeveloperentry toinstructions. Do not leave it in the array as well; you will have said it twice. max_tokenstomax_output_tokens. If your existing code already usesmax_completion_tokensbecause it targets reasoning models, that maps to the same place.response_formattotext.format. A JSON Schema configuration moves inside the nested object, keeping itstype,name,schemaandstrictmembers; the surrounding envelope is what changed, not the schema. The existing pages on the JSON Schema response format and strict mode still apply to the schema itself.streamstays, but what it streams does not: nameless chunks with a[DONE]sentinel are replaced by named semantic events. Budget for a consumer rewrite rather than a parameter change, and see normalising streaming dialects.temperature,top_pandmetadatacarry over by name. Note that reasoning models reject sampling parameters regardless of which endpoint you send them to, so a rejection here is usually about the model rather than the migration.- Two parameters are new and have no old counterpart:
store, which decides whether the provider retains the response, andprevious_response_id, which is how a stored response is continued. Both default in ways worth checking explicitly rather than assuming, because one of them decides whether your prompt content sits on the provider’s side afterwards.
Reading the reply
The convenience accessor covers the common case, but any code that did more than print the text needs the real walk. Anything that inspected finish_reason, counted tool calls, or logged token usage is in that category.
text_parts, tool_calls = [], []
for item in resp.output:
if item.type == "message":
for part in item.content:
if part.type == "output_text":
text_parts.append(part.text)
elif item.type == "function_call":
tool_calls.append(item)
truncated = resp.status == "incomplete"
reason = resp.incomplete_details.reason if truncated else NoneSwitch on type and ignore what you do not recognise. An item type you have never seen — a reasoning item, or something added next quarter — should be skipped, not raise. That single habit is what makes the consumer survive an additive change.
Usage keys change name, and this is the migration’s quietest breakage. prompt_tokens and completion_tokens become input_tokens and output_tokens, with cached-input and reasoning counts in per-direction detail objects. Cost code that reads those keys out of a dictionary will find nothing and record zero, and a bill that reads as zero looks like a saving rather than a bug — the cost dashboard breaking after a migration is almost always this.
The tool round trip
Here the mapping stops being one-to-one, so it is worth writing out both halves.
Defining a tool. The chat-completions envelope nests: an entry of type: "function" containing a function object with name, description and parameters. The responses envelope flattens those onto the tool entry itself alongside its type. The JSON Schema in parameters is byte-identical between them; only the wrapper moves.
Receiving a call. Instead of a tool_calls array on the assistant message, each call is its own function_call item in output, carrying name, arguments as a JSON string, and a call_id. The identifier is the part to be careful with: it is the value you must echo back, and it is not necessarily the same field as the item’s own id.
Returning a result. This is the genuine shape change. Chat Completions expects a new message with role: "tool" and a tool_call_id. The responses shape expects an input item of type function_call_output carrying call_id and output — not a message, and not a role. You also append the model’s own function_call item back onto the input before it, so the model sees its call and the result as consecutive items.
# continue the turn after running the tool
convo = input_items + [call_item, {
"type": "function_call_output",
"call_id": call_item.call_id,
"output": json.dumps(tool_result),
}]
resp2 = client.responses.create(model=MODEL, input=convo, tools=TOOLS)Two failure modes account for most of the pain here. Echoing the wrong identifier produces a rejection about an unmatched call rather than a clear message about the field. And omitting the original function_call item while including its output leaves the model with a result for a call it cannot see, which does not error at all — it produces a confused answer. Where the endpoint is holding state for you, you send only the new items rather than rebuilding the array, and that difference is the subject of the state-mapping page.
Doing it safely
- Put the current call behind one function with a narrow signature, if it is not already. Everything below happens inside it.
- Write the new call alongside the old one and run both against the same recorded inputs offline. Compare the extracted text, the truncation flag and the usage numbers field by field — not by eye.
- Migrate the reply reader before you migrate anything that writes. Walking
outputis where the silent breakages are, and it is testable without sending a request. - Fix cost and logging code explicitly. Grep for
prompt_tokens,completion_tokens,finish_reasonandchoicesacross the repository; every hit is a call site the migration touches, including ones in dashboards and jobs that no test covers. - Do the tool round trip last, with a single tool, and assert on the request body you build rather than on the model’s answer.
- Decide
storedeliberately and write the decision down. It is a data-retention choice, not a performance one, and it belongs in the same conversation as your retention clause. - Ship behind a flag with the old path intact, so a rollback trigger is a config change rather than a revert.