Migrate Function Calling from Chat Completions to Responses API
Move the whole function-calling loop: validate the model’s arguments, run the authorized handler, return the result with its original call_id, and continue until the user gets a final answer. The JavaScript example preserves typed state and handles failed or unfinished responses.
On this page

To migrate function calling from Chat Completions to the Responses API, change the tool definition and the entire execution loop. Read every typed output item, validate each function call, run it in your application, return a function_call_output with the original call_id, then make another Responses request. Finish only when you have both the required application result and an acceptable final answer.
Custom functions still run in your application. A model-generated call is a request to do work; HTTP 200 or a sentence saying “I checked your order” does not prove that work happened. OpenAI recommends Responses for new projects while continuing to support Chat Completions. An existing application should preserve its working business behavior during the move.
This guide keeps the JavaScript implementation complete: a scoped shipment lookup, argument validation, an allowlisted handler, matched tool outputs, manual state replay, a streaming adapter, and a recovery plan. The code uses demonstration data. Its application logic was checked offline with fake Responses and synthetic events; no SDK integration, live model, real account, or production shipment system was tested.
The contract changes at eight points
| Concern | Chat Completions | Responses API | What to preserve |
|---|---|---|---|
| Request | client.chat.completions.create() | client.responses.create() | Compatible model and effective configuration |
| Conversation input | messages | input items | User request, instructions, relevant history |
| Returned data | choices[0].message | Heterogeneous response.output | Every applicable typed item |
| Function definition | { type: "function", function: { … } } | { type: "function", name, parameters, strict, … } | Tool meaning and schema version |
| Function request | message.tool_calls | type: "function_call" items | Name, arguments, original call_id |
| Application result | role: "tool", tool_call_id | type: "function_call_output", call_id | The result joined to its actual call |
| State | Resend messages | Manual replay, response chaining, or Conversation | One coherent state strategy |
| Streaming | choices[].delta | Typed Responses events | Complete calls before execution |
These shapes come from the current migration and function-calling guides. Do not translate tool results into assistant messages or use an item’s id in place of call_id. The optional item ID identifies the output item; call_id identifies the function call whose result you are returning.

The result carries the original function call ID; replay also preserves relevant typed state.
response.output_text is an SDK convenience for aggregated text, not the full response state or a universal raw REST field. The output array can contain messages, reasoning, and zero, one, or several function calls. A loop that reads only output[0] can silently lose work.
Freeze a baseline before changing the endpoint
Start with a harmless application read that already works through Chat Completions. For example, ask for shipment ORD-1042 in a test account. Save the normalized request, instructions, tool schema, validated arguments, handler invocation count, actual result, and final answer. Keep latency and token measurements separate from functional acceptance.
For the candidate Responses path, keep the same effective model and reasoning configuration when compatible. As of October 7, 2026, the current model compatibility guide says GPT-6 Astra and GPT-6.1 Sol require Responses for tool calling, even though they support non-tool Chat requests. GPT-6 Sol and Luna support Chat function calling only with reasoning_effort: "none". Responses uses reasoning.effort; Astra and 6.1 Sol do not support none. With non-none reasoning, remove unsupported sampling and logprob parameters. Check your selected model’s contract rather than changing models as an incidental part of the migration.
Before moving a schema, choose its strictness deliberately. With explicit strict: true, each object needs additionalProperties: false, and every property must be in required. Optional values can use a nullable type while remaining required. An incompatible explicit strict schema is rejected. If you omit strict, Responses attempts strict normalization where possible and otherwise falls back to best-effort calling; omission guarantees neither strict output nor rejection. Chat remains non-strict by default, and explicit strict: false requests best effort in Responses. These distinctions are documented in strict mode.
The example below uses a simple explicit strict schema and still validates every argument inside the application. Schema compliance does not authenticate a user, authorize access to an order, or make a returned value true.
A complete Responses function loop in JavaScript
Save the first block as lookup-loop.mjs. It exports the whole application loop without creating an SDK client. That makes the same code testable with a fake transport before connecting it to OpenAI.
The trusted account ID comes from the application session, never from model arguments. The demonstration lookup uses an account-and-order key, so even a syntactically valid order ID cannot read another account’s row. In your application, replace the local map with a query that enforces the same access rule.
export const tools = [{
type: "function",
name: "lookup_shipment",
description: "Read the shipment status for an order in the signed-in account.",
strict: true,
parameters: {
type: "object",
properties: { order_id: { type: "string" } },
required: ["order_id"],
additionalProperties: false
}
}];
// Demonstration data only; the account comes from your authenticated session.
const shipments = new Map([
["account-demo:ORD-1042", { status: "packed", dispatch_date: "2026-10-08" }]
]);
const handlers = Object.freeze({
async lookup_shipment(args, accountId) {
const row = shipments.get(`${accountId}:${args.order_id}`);
return row ? { ok: true, order_id: args.order_id, ...row }
: { ok: false, error: "not_found_in_account" };
}
});
function checkCall(call) {
if (typeof call.call_id !== "string" || !call.call_id.trim() ||
typeof call.name !== "string" || typeof call.arguments !== "string") {
throw new Error("Malformed function-call envelope");
}
if (call.async === true || call.namespace != null ||
(call.caller != null && call.caller.type !== "direct") ||
(call.status != null && call.status !== "completed")) {
throw new Error("Unsupported function-call mode");
}
}
function validatedArgs(call) {
if (!Object.hasOwn(handlers, call.name)) throw new Error("unknown_tool");
let args;
try { args = JSON.parse(call.arguments); }
catch { throw new Error("invalid_json"); }
if (args === null || Array.isArray(args) || typeof args !== "object" ||
Object.keys(args).length !== 1 || !Object.hasOwn(args, "order_id") ||
typeof args.order_id !== "string" || !/^ORD-\d{4}$/.test(args.order_id)) {
throw new Error("invalid_order_id");
}
return args;
}
function executeOnce(call, accountId, ledger) {
const signature = JSON.stringify([call.name, call.arguments]);
const existing = ledger.get(call.call_id);
if (existing) {
if (existing.signature !== signature) throw new Error("Reused call_id changed");
return existing.pending;
}
// Schedule execution after the promise has been stored: concurrent duplicates
// share the pending result instead of starting a second handler.
const pending = Promise.resolve().then(async () => {
let result;
let args;
try { args = validatedArgs(call); }
catch (error) { result = { ok: false, error: error.message }; }
if (args) {
try { result = await handlers[call.name](args, accountId); }
catch { result = { ok: false, error: "lookup_failed" }; }
}
return {
type: "function_call_output",
call_id: call.call_id,
output: JSON.stringify(result)
};
});
ledger.set(call.call_id, { signature, pending });
return pending;
}
export async function runLookup({ create, toInput, model, accountId,
question, reasoning, maxRounds = 5 }) {
if (!model || !accountId || typeof question !== "string" || !question.trim()) {
throw new Error("model, authenticated accountId, and question are required");
}
if (!Number.isInteger(maxRounds) || maxRounds < 1) throw new Error("Bad round limit");
const history = [{ role: "user", content: question }];
const ledger = new Map(); // One account and one job; not durable storage.
const instructions = "Look up shipment status using lookup_shipment. " +
"Report only the returned data. Explain rejected or missing lookups. " +
"Do not claim a shipment lookup succeeded without a successful tool result.";
const receipts = [];
for (let round = 0; round < maxRounds; round++) {
const response = await create({
model, instructions, input: structuredClone(history), tools,
...(reasoning ? { reasoning } : {}),
parallel_tool_calls: false, store: false, max_output_tokens: 1200
});
if (response.status !== "completed" || response.error) {
throw new Error(`Generation stopped: ${response.status}; ` +
`${response.incomplete_details?.reason ?? response.error?.code ?? "unknown"}`);
}
if (!Array.isArray(response.output)) throw new Error("Missing typed output");
const calls = [];
const ids = new Set();
const text = [];
for (const item of response.output) {
if (!["message", "reasoning", "function_call"].includes(item.type)) {
throw new Error(`Unsupported output item: ${item.type}`);
}
if (item.type === "function_call") {
checkCall(item);
if (ids.has(item.call_id)) throw new Error("Duplicate call_id in one response");
ids.add(item.call_id);
calls.push(item);
}
if (item.type === "message") {
if (item.status != null && item.status !== "completed") {
throw new Error("Unfinished assistant message");
}
for (const part of item.content ?? []) {
if (part.type === "refusal") throw new Error("Model refused the request");
if (part.type === "output_text" && item.phase !== "commentary") {
text.push(part.text);
}
}
}
}
// Use the SDK's replay conversion, rather than reducing state to text.
// It must retain encrypted reasoning and assistant message phase.
history.push(...toInput(response.output));
if (calls.length === 0) {
const final = text.join("\n").trim();
if (!final) throw new Error("No final answer");
if (receipts.length === 0) throw new Error("Lookup never executed");
const succeeded = receipts.some(item => JSON.parse(item.output).ok === true);
return { outcome: succeeded ? "lookup_returned" : "lookup_rejected", final, receipts };
}
// Sequential dispatch bounds downstream concurrency at one. Every call
// gets its own original call_id; array position is never the join key.
const outputs = [];
for (const call of calls) outputs.push(await executeOnce(call, accountId, ledger));
receipts.push(...outputs);
history.push(...outputs);
}
throw new Error("Application round limit reached");
}Save this small connection layer as run-lookup.mjs beside it. In a server-side project with the current openai package installed, it supplies the SDK call and the replay helper documented by OpenAI. It reads credentials through the SDK’s normal server environment; do not put an API key in browser code.
import OpenAI from "openai";
import { toResponseInputItems } from "openai/lib/responses/ResponseInputItems";
import { runLookup } from "./lookup-loop.mjs";
const model = process.env.OPENAI_MODEL;
if (!model) throw new Error("Set OPENAI_MODEL to your compatible model.");
const client = new OpenAI({ timeout: 15_000, maxRetries: 0 });
const effort = process.env.OPENAI_REASONING_EFFORT;
const result = await runLookup({
create: request => client.responses.create(request),
toInput: toResponseInputItems,
model,
reasoning: effort ? { effort } : undefined,
accountId: "account-demo", // Demo only; use your authenticated server session.
question: "What is the shipment status for order ORD-1042?"
});
console.log(JSON.stringify(result, null, 2));When you are ready for an authorized staging call, set OPENAI_API_KEY, choose a function-capable OPENAI_MODEL, preserve any compatible reasoning effort, and run node run-lookup.mjs. The connection layer is a documentation-aligned example; its SDK import path and live execution were not exercised in the offline checks. Confirm that the installed SDK exposes toResponseInputItems before rollout; the current conversation-state JavaScript example uses that helper.
A successful demonstration lookup should return a receipt with ok: true, order_id: "ORD-1042", and status: "packed", followed by a final answer grounded in those values. That is an expected result from the local fixture, not a captured model response. The returned outcome records whether a handler supplied a successful result; it does not automatically prove the final text interpreted the result correctly.
Why the loop waits, rejects, or continues
The loop validates the complete output before dispatching anything. An unsupported output type, malformed call_id, unfinished call, or unsupported async/program/namespace mode stops the baseline implementation. Those are distinct orchestration features; accepting them accidentally would leave the application with work it does not know how to manage.
An unknown tool name, invalid JSON, or invalid order ID becomes a matched error output without running a handler. not_found_in_account is also an application result. The next model turn can explain either condition, but the caller sees lookup_rejected rather than a successful lookup. A refusal, empty final answer, or final answer with no preceding lookup stops the job.
Every accepted call receives its own function_call_output; the loop scans the whole array and dispatches sequentially. The initial request sets parallel_tool_calls: false, which permits zero or one call per turn. It does not guarantee a call or a successful task. If you later enable parallel calls, the loop can process the resulting batch without using array positions as IDs. Add bounded concurrency only for independent operations. See the documented parallel-calling and tool-choice behavior.
The ledger stores a pending promise before execution can begin. Repeated calls with the same ID and arguments within this job reuse the saved result; a changed signature under the same ID stops execution. Duplicate IDs inside a single response are rejected before dispatch. This is bounded, in-process deduplication. It is useful for the read-only example, but is not durable business idempotency.
Five rounds, one downstream lookup at a time, a 1,200-token output cap, and the connection layer’s 15-second request timeout are demonstration limits. Choose limits for your own service. max_output_tokens covers visible output and reasoning; max_tool_calls limits built-in tools, not your custom dispatcher’s rounds or downstream operations. The current Responses create reference defines those request parameters. Track a separate end-to-end deadline and business-operation budget when your application requires them.
Choose one state strategy
For the first migration, manual replay makes it easy to inspect what the next request receives. The program calls toResponseInputItems(response.output) and appends the matched function outputs afterward. It preserves returned reasoning and assistant message metadata instead of rebuilding history from the final text. Current conversation-state guidance says encrypted reasoning is returned by default and calls out preserving assistant phase; reasoning.encrypted_content also remains available as an explicit include field.
| Strategy | What the next request sends | Main decision |
|---|---|---|
| Manual replay | Relevant prior input, replayable output items, then tool outputs | Your application owns ordering, trimming, retention, and recovery |
previous_response_id | Prior response ID plus new input, such as the matched function outputs | The ID must remain available; resend top-level instructions |
| Conversation | A persistent Conversation identifier and new input | Inputs and outputs are appended after completion; manage access and long-lived state |
Do not resend a full history and also chain to that same history. Do not combine previous_response_id and conversation in one request. Top-level instructions are not carried over by response chaining, so supply them again. These rules are in the state guide and create reference.
Does previous_response_id work over HTTP?
Yes. OpenAI documents response chaining over HTTP as well as WebSocket mode. A valid HTTP continuation can send the saved response ID and a function_call_output whose call_id matches a call in that prior response. WebSocket continuation uses the same lineage concept but adds a connection-local cache with its own recovery behavior. See the HTTP chaining examples and WebSocket distinction.
If your service returns an error saying continuation is “only supported on Responses WebSocket v2,” first identify the actual endpoint, provider, deployment, and request shape. That wording does not establish an OpenAI-wide HTTP limitation. A gateway may implement only part of Responses. Check whether the ID belongs to that route, whether the original response is available, and whether you supplied the actual call’s ID rather than its item ID. Do not switch transports merely to hide a missing call_id.
A store: false request should be paired with a usable manual replay path rather than relying on ordinary stored response retrieval. It does not enroll your project in Zero Data Retention or erase application and gateway logs. Standard stored Responses have a default 30-day application-state period, subject to documented exceptions; Conversation items are not subject to that 30-day TTL. Chained previous input still contributes to billed context. Check the data controls and state retention guidance for your organization and features before choosing a persistence strategy.
Migrate the streaming reducer, not just the endpoint
Streaming needs a typed event consumer. For custom function calls, register the call from response.output_item.added, accumulate argument deltas by item_id and output_index, reconcile the completed arguments, and verify the completed call item. Never execute partial JSON.
The exact response.function_call_arguments.done schema contains arguments, item_id, output_index, sequence_number, and type. It does not contain a complete function item or a call_id. Keep the original ID from the actual call item; response.output_item.done supplies that completed item. Do not synthesize a call ID from the argument event.

The timeline shows this example’s release policy: a failed or unfinished stream does not reach the dispatcher.
Save the following as completed-stream.mjs. Its conservative release policy waits for response.completed before returning anything to the dispatcher. Completing one argument item does not prove the whole response completed. It also verifies repeated event identities and rejects missing or conflicting call data.
export async function readCompletedStream(events) {
const pending = new Map();
const seen = new Map();
for await (const event of events) {
if (Number.isInteger(event.sequence_number)) {
const fingerprint = JSON.stringify(event);
if (seen.has(event.sequence_number)) {
if (seen.get(event.sequence_number) !== fingerprint) {
throw new Error("Conflicting repeated stream event");
}
continue;
}
seen.set(event.sequence_number, fingerprint);
}
if (event.type === "error" || event.type === "response.failed" ||
event.type === "response.incomplete") {
throw new Error(`Stream stopped: ${event.type}`);
}
if (event.type === "response.output_item.added" &&
event.item.type === "function_call") {
const item = event.item;
if (!item.id || !item.call_id || pending.has(item.id)) {
throw new Error("Invalid stream call registration");
}
pending.set(item.id, { item, index: event.output_index, delta: "", args: null, done: null });
}
if (event.type === "response.function_call_arguments.delta" ||
event.type === "response.function_call_arguments.done") {
const state = pending.get(event.item_id);
if (!state || state.index !== event.output_index || state.done) {
throw new Error("Arguments without a matching open call item");
}
if (event.type.endsWith(".delta")) {
if (state.args !== null) throw new Error("Delta after completed arguments");
state.delta += event.delta;
} else {
if (state.args !== null || (state.delta && state.delta !== event.arguments)) {
throw new Error("Conflicting completed arguments");
}
state.args = event.arguments; // The event does NOT supply a call_id.
}
}
if (event.type === "response.output_item.done" &&
event.item.type === "function_call") {
const state = pending.get(event.item.id);
if (!state || state.index !== event.output_index || state.done ||
state.args === null || state.args !== event.item.arguments ||
state.item.call_id !== event.item.call_id || state.item.name !== event.item.name) {
throw new Error("Completed call does not match buffered arguments");
}
state.done = event.item;
}
if (event.type === "response.completed") {
const response = event.response;
if (response.status !== "completed" || !Array.isArray(response.output)) {
throw new Error("Invalid completed response");
}
const calls = response.output.filter(item => item.type === "function_call");
if (calls.length !== pending.size) throw new Error("Missing final stream call");
for (const call of calls) {
const done = pending.get(call.id)?.done;
if (!done || done.call_id !== call.call_id || done.name !== call.name ||
done.arguments !== call.arguments) throw new Error("Unmatched final call");
}
return response; // Only now can runLookup validate and execute anything.
}
}
throw new Error("Stream ended before response.completed");
}To use this adapter, add its import to run-lookup.mjs and replace the create callback:
import { readCompletedStream } from "./completed-stream.mjs";
// Inside the runLookup options:
create: async request => readCompletedStream(
await client.responses.create({ ...request, stream: true })
),That replacement is a connection snippet for the preceding complete program, not a separate executable file. Text and reasoning events remain in the final response used for replay; the reducer only buffers the call details it needs to check. It consumes decoded SDK events rather than raw network bytes. If you implement raw SSE parsing yourself, separately test incremental UTF-8 decoding, split frames, and cancellation.
response.failed and response.incomplete stop this adapter. The non-streaming loop also rejects every status other than completed, including queued, in_progress, and cancelled. On an incomplete response, inspect incomplete_details before choosing a next step: a token limit, content filter, or other documented reason calls for different handling. The relevant streaming guide and incomplete event reference distinguish these states. Transport EOF without response.completed is a failed collection attempt, even if the UI already displayed text.
Recover without repeating a business operation
For a shipment read, retrying the lookup can be acceptable after you classify the failure. A refund, email, booking, or payment needs a different recovery contract. The model’s call_id is a protocol join, not permission to act and not an operation key that survives a new model-generated call.
Before a write, derive a business operation key from trusted application data—for example, an authorized refund request ID scoped to the account. Reserve it atomically in durable storage, record the downstream operation identifier, commit the result, and return that saved result on retry. A second worker must see the reservation or committed result before it can act. If the downstream service supports idempotency keys, use the same business key there too. This is application design, not an API guarantee of exactly-once execution.
A timeout after submitting a write can leave the outcome unknown. Query the downstream operation or reconcile your ledger; do not label it “failed” and repeat it blindly. If the function succeeded but the next model request failed, keep the committed tool result and resume the model step with the corresponding state. Do not restart the business action to regenerate the final sentence.
Separate request retries from operation retries. The example disables automatic SDK retries in its connection layer so failures are visible during staging, but that alone cannot prevent duplicate writes elsewhere. A production retry policy needs the durable ledger, suitable downstream reconciliation, and a bounded request budget.
Keep a recoverable execution record with the job ID, account scope, response ID, call ID, tool/schema version, sanitized arguments or safe references, operation key, committed result reference, and failure class. Protect this record as application data. General logs should omit keys, sensitive payloads, and unnecessary customer content.
Diagnose failures by the broken contract
| Symptom | First check | Next action |
|---|---|---|
| Schema rejected | Flat function definition, explicit strict constraints | Correct every object’s additionalProperties and required properties |
| No function call | Submitted tool names, tool choice, complete output types | Confirm the model may call the tool; do not execute merely because text mentions it |
function_call_output requires call_id | Actual function_call.call_id and outgoing result | Copy the original call ID unchanged; never use the item’s id |
| “No matching tool call” | State strategy, route, response availability, call/result pairing | Restore the relevant call context and matched output |
| Lookup ran but no final answer | Follow-up request and response status | Continue from the saved result; classify generation failure separately |
| One call disappears | Full output scan and result count | Process every call in the batch, each with its own ID |
| Stream closes after arguments | Item completion versus whole-response completion | Stop before dispatch; diagnose incomplete/error/EOF |
| Instructions or reasoning disappear | Actual next-request input and replay conversion | Preserve phase and reasoning; resend top-level instructions |
| Write happens twice | Durable operation key and downstream receipt | Reconcile and return the committed result before any new attempt |
Empty output, model refusal, rejected arguments, failed generation, and an unknown downstream outcome are different failures. A single “request succeeded” metric cannot tell you whether the user’s task succeeded.
Staging acceptance checklist
The offline checks for this guide exercised the actual application module with fake transports: a call after a reasoning item, encrypted reasoning and message-phase replay, matched successful results, invalid JSON and argument shapes, a non-allowlisted tool, account isolation, multiple calls, repeated calls, conflicting IDs, refusal, empty output, failed/incomplete status, and the round limit. Synthetic streams exercised split arguments, repeated events, exact .done fields without call_id, mismatched completed calls, error events, and premature EOF. These checks prove those local branches, not SDK availability or OpenAI server acceptance.
For your rollout, require evidence beyond those fixtures:
- The old and new paths receive the same safe request, trusted account, instructions, tool/schema version, and compatible model configuration.
- The authorized handler runs for valid input; malformed, unknown, or out-of-scope requests cannot access or change unrelated data.
- Every tool output retains its original call ID, and a multi-call batch loses no result.
- Replayed state preserves relevant reasoning and assistant phase, and instructions survive a second turn.
- Streaming waits for complete calls and handles incomplete responses, cancellation, duplicate events, and broken connections.
- The final answer agrees with the actual tool result; no-call text or a model’s promise is not accepted as proof of execution.
- Write retries, concurrent workers, and process restarts return the same committed business result without repeating the operation.
- Request duration, application rounds, downstream concurrency, token usage, and operational costs stay within your selected limits.
- Recovery resumes the correct state and saved result after a model failure or lost connection.
- A rollback preserves already committed operations and returns traffic to a previously validated model, endpoint, configuration, and state path.
Stop traffic expansion on an unmatched call ID, duplicate write, lost call, policy/state regression, or materially incorrect final answer. Returning to Chat is only a rollback when the selected model and configuration actually support the old tool path. A Responses-only tool model cannot be rolled back by changing one method name.
Source and provider boundaries
This is an OpenAI custom-function migration. Azure, AliCloud, and compatible gateways may differ in base paths, authentication, deployment IDs, response-ID lifetime, retention, and event support. A familiar SDK shape or HTTP 200 is not proof of feature parity. Validate the complete loop on the route you intend to serve; do not transplant a provider-specific restriction into OpenAI’s contract.
If authentication is the unfinished step, use the API key setup guide to establish the first server-side request. If you need a typed final object instead of an application action, the Structured Outputs versus function calling guide explains that choice and when both belong in one flow.
FAQ
Do I have to migrate every Chat Completions application now?
No. Chat Completions remains supported, while OpenAI recommends Responses for new projects. Migrate when the chosen model or required features need it, or when the state and tool interfaces fit your application better. Preserve a validated old path where compatible. See the migration guide.
Does strict: true make the function run safely?
It makes compatible arguments adhere to the declared schema. Your application still owns validation, access control, execution, idempotency, and checking the actual result. A valid order ID does not authorize access to that order. The strict-mode contract governs shape, not business permission.
Can I use previous_response_id for HTTP function results?
Yes, OpenAI supports HTTP chaining. Send the available prior response ID and the result with the original call’s call_id, and resend your instructions. A gateway may have a different implementation; inspect the real route before treating its error as a universal API rule. The conversation-state guide documents both HTTP and WebSocket continuation.
Is response.function_call_arguments.done enough to execute?
It tells you the argument string is finalized. You still need the actual call item’s name and original call ID, application validation and authorization, and your chosen response-completion policy. This guide waits for response.completed. The argument-done schema does not supply call_id.
Can I use call_id as the idempotency key for a refund?
Use it to match the tool result, and use a separate durable business key to deduplicate the refund. A new call can propose the same operation under a new protocol ID. After a timeout, reconcile the downstream outcome before retrying. The in-memory ledger in this guide demonstrates bounded lookup deduplication, not restart-safe refunds.





