Skip to the solution
MediumSystem Design15 min read

Tool calling vs function calling vs structured output — what changed in 2025?

780 views

The problem

Question presented to candidate: "People on the team use 'function calling', 'tool calling' and 'structured output' as if they were the same thing. Untangle them for me: what does each one mean, how are they related, and what actually changed in the APIs during 2025 that we should design around?"

What a strong answer should cover:

  • Function calling is OpenAI's original 2023 name; tool calling (Anthropic: tool use) is the general name for the same mechanism: the model emits a structured request naming a tool and its arguments, and your code executes it
  • Structured output constrains the model's final answer to a JSON Schema; a tool call's arguments are themselves structured output, which is why the old way to get structured answers was a forced tool call
  • The milestones: functions (June 2023), tools/tool_choice with parallel calls and JSON mode (November 2023), strict schemas for both answers and tool arguments (OpenAI, August 2024), MCP for sharing tools (November 2024)
  • What changed in 2025: OpenAI's Responses API with flat tool definitions and function_call items, MCP tool output schemas, Gemini JSON Schema on all active models, Anthropic structured outputs and strict tool use, and SDKs folding structured output into the normal text call
  • The design rule that follows: tools for actions and data the model needs, structured output for the shape of the answer; keep one internal tool and schema definition and adapt it per provider

Code / implementation expected: Optional — mapping one tool definition to two providers' formats, or showing a tool-call loop next to a schema-constrained answer.

  • tool-calling
  • function-calling

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read · 10 sections

Read the code

One tool, three wire formats, one internal ToolCall type
// One tool definition, three wire formats, one internal ToolCall type.
// Response objects are canned stubs shaped like each API (no network).
const TOOL = {
  name: "get_order_status",
  description: "Look up the shipping status of one order by its id. Use when the user asks where an order is.",
  params: {
    order_id: { type: "string", description: "Order id such as A-17" },
    include_items: { type: "boolean", description: "Also return the line items" },
  },
  required: ["order_id"],
};
const show = (label, v) => console.log(`${label}\n  ${JSON.stringify(v)}`);

function jsonSchema(t, strictAllRequired) {
  const props = {};
  for (const [k, p] of Object.entries(t.params)) {
    const optional = !t.required.includes(k);
    // OpenAI strict mode: every key must be required, so "optional" becomes "may be null".
    props[k] = strictAllRequired && optional ? { ...p, type: [p.type, "null"] } : { ...p };
  }
  return {
    type: "object", properties: props,
    required: strictAllRequired ? Object.keys(t.params) : t.required,
    additionalProperties: false,
  };
}
const toChat = (t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: jsonSchema(t, true), strict: true } });
const toResponses = (t) => ({ type: "function", name: t.name, description: t.description, parameters: jsonSchema(t, true), strict: true });
const toAnthropic = (t) => ({ name: t.name, description: t.description, input_schema: jsonSchema(t, false), strict: true });

console.log("== 1. the same tool on the wire");
show("OpenAI Chat Completions (nested under function):", toChat(TOOL));
show("OpenAI Responses (flat):", toResponses(TOOL));
show("Anthropic Messages (input_schema):", toAnthropic(TOOL));

// Canned replies in each API shape. Note: OpenAI sends arguments as a JSON STRING.
const REPLIES = {
  chat: { choices: [{ finish_reason: "tool_calls", message: { role: "assistant", content: null, tool_calls: [
    { id: "call_a1", type: "function", function: { name: "get_order_status", arguments: '{"order_id":"A-17","include_items":null}' } }] } }] },
  responses: { output: [{ type: "function_call", call_id: "call_b2", name: "get_order_status", arguments: '{"order_id":"A-17","include_items":null}' }] },
  anthropic: { stop_reason: "tool_use", content: [
    { type: "text", text: "Let me check that order." },
    { type: "tool_use", id: "toolu_c3", name: "get_order_status", input: { order_id: "A-17" } }] },
};
function normalise(api, r) {
  if (api === "chat") return r.choices[0].message.tool_calls.map((c) => ({ id: c.id, name: c.function.name, args: JSON.parse(c.function.arguments) }));
  if (api === "responses") return r.output.filter((o) => o.type === "function_call").map((o) => ({ id: o.call_id, name: o.name, args: JSON.parse(o.arguments) }));
  return r.content.filter((b) => b.type === "tool_use").map((b) => ({ id: b.id, name: b.name, args: b.input }));
}
const stopSignal = { chat: (r) => r.choices[0].finish_reason, responses: (r) => r.output.map((o) => o.type).join(","), anthropic: (r) => r.stop_reason };
function runTool(args) {
  const out = { order_id: args.order_id, status: "shipped" };
  if (args.include_items) out.items = ["mug"];
  return out;
}
const resultMessage = {
  chat: (c, out) => ({ role: "tool", tool_call_id: c.id, content: out }),
  responses: (c, out) => ({ type: "function_call_output", call_id: c.id, output: out }),
  anthropic: (c, out) => ({ role: "user", content: [{ type: "tool_result", tool_use_id: c.id, content: out }] }),
};

console.log("\n== 2. parse each reply into one internal type, run the tool, send the result back");
for (const api of Object.keys(REPLIES)) {
  const calls = normalise(api, REPLIES[api]);
  console.log(`${api.padEnd(9)} signal=${stopSignal[api](REPLIES[api])}`);
  for (const c of calls) {
    console.log(`  internal : ${JSON.stringify(c)}`);
    const out = JSON.stringify(runTool(c.args));
    console.log(`  reply    : ${JSON.stringify(resultMessage[api](c, out))}`);
  }
}

console.log("\n== 3. structured output for the FINAL answer (no tool runs)");
const ANSWER = { name: "order_answer", params: { status: { type: "string", description: "Shipping status" }, eta_days: { type: "integer", description: "Days until delivery" } }, required: ["status", "eta_days"] };
show("OpenAI Chat Completions response_format:", { type: "json_schema", json_schema: { name: ANSWER.name, strict: true, schema: jsonSchema(ANSWER, true) } });
show("OpenAI Responses text.format:", { format: { type: "json_schema", name: ANSWER.name, strict: true, schema: jsonSchema(ANSWER, true) } });
show("Anthropic output_config:", { format: { type: "json_schema", schema: jsonSchema(ANSWER, false) } });
console.log("Older trick: define the answer as a tool and force the model to call it");
show("  Anthropic tool_choice:", { tool_choice: { type: "tool", name: ANSWER.name } });
show("  OpenAI Chat Completions tool_choice:", { tool_choice: { type: "function", function: { name: ANSWER.name } } });

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 31 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

How do you manage conversations that outgrow the context window?

Continue