Azure AI/ML

Function Calling and Structured Outputs in Azure OpenAI: Reliable Tool Use and JSON Schema Enforcement

You ship a chatbot that answers questions about orders. It works in the demo. Then a customer asks “where’s order 41982?” and the model replies, in fluent prose, that the order shipped Tuesday and will arrive Friday — a complete fabrication, because the model has no idea where that order is. It just sounds certain. The fix is not a better prompt. The fix is to stop asking the model to know the answer and start asking it to call your code that knows the answer. That is function calling (also called tool use): you describe the functions your app can run, the model decides which one to call and with what arguments, your code runs it, and you hand the result back for the model to phrase. The model becomes the router and the writer; your code stays the source of truth.

The second half of the problem is the shape of what comes back. Even when the model picks the right tool, it has historically returned arguments as best-effort JSON — usually valid, occasionally with a trailing comment, a markdown fence, an extra field, or a number where you wanted a string. One malformed response in a thousand is a 3 a.m. page. Structured Outputs closes that gap: you attach a JSON Schema and turn on strict mode, and the platform constrains generation so the output is guaranteed to match your schema — every required field present, no extras, the right types. Together, function calling and structured outputs turn a probabilistic text generator into something you can wire into a transactional system without a defensive parser around every call.

This article is a step-by-step implementation guide for Azure OpenAI. You will declare tools the model can call, force its arguments and its final answers to be schema-valid, build the full call → execute → feed-back loop, handle parallel tool calls and refusals, and deploy the whole thing with managed identity instead of keys. The centrepiece is a hands-on lab you run end to end in both the Azure portal and the az CLI, with a Bicep version, expected output at each step, validation and teardown. By the end you will know exactly which knobs (tools, tool_choice, response_format, strict, parallel_tool_calls) do what, where each one bites, and how to keep the bill and the blast radius small.

What problem this solves

A raw chat completion is a text predictor. Ask it for live data and it hallucinates; ask it for JSON and it usually complies but gives you no contract. Both failure modes are silent — the response looks fine until a downstream parser chokes or a customer reads a fabricated fact. Teams paper over this with regex extraction, “respond ONLY with JSON” prompts, retry loops that re-ask on parse failure, and brittle string matching to detect intent. All of it is wasted effort that function calling and structured outputs make unnecessary.

What breaks without this: the model invents data it cannot know (order status, account balance, today’s price); JSON parsing fails intermittently under load because the model wrapped the object in ```json or added a chatty preamble; intent detection done with keyword matching misroutes edge cases; and an “agent” that should call a search API instead answers from stale training data. Each of these is a correctness bug that hides until production traffic finds it.

Who hits this: anyone building a chatbot over private data, an internal copilot that takes actions, a retrieval-augmented assistant that needs to call search, a workflow that extracts structured fields from documents or emails, or any agent that orchestrates tools. The moment your LLM output feeds another system — a database write, an API call, a UI that renders specific fields — you need the model’s output to be a contract, not a suggestion. That is precisely what this pair of features delivers.

Here is the field at a glance — the two mechanisms, what each guarantees, and when you reach for it:

Mechanism What you declare What it guarantees Reach for it when
Function / tool calling A list of callable functions with JSON-schema parameters The model returns which function + arguments (you run it) The model must fetch live data or take an action
Structured Outputs (response_format) A JSON schema for the model’s final answer The reply is schema-valid JSON, every time You need the answer itself as typed data
Strict mode (strict: true) The same schemas, with additionalProperties:false Constrained decoding — no drift, no extra keys Output feeds a parser/DB and must never break
tool_choice auto / required / a named tool / none Forces, frees or forbids tool use You must control whether/which tool runs

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should have an Azure subscription with access to Azure OpenAI (the service is access-gated; if you have a working resource you are set) and the ability to create resources in a resource group. You should be comfortable running az in Cloud Shell, reading JSON, and reading basic Python or a curl request. You need a model deployment whose model supports tools — the GPT-4o, GPT-4o mini and GPT-4.1 families do; older gpt-35-turbo builds support a legacy form. Structured Outputs (strict mode) requires a model and API version new enough to support response_format: json_schema — use a recent 2024-08-01-preview or later API version and a current model.

This sits in the AI/ML application track, one layer above raw model calls. It assumes you have already deployed a model and made a first call — if not, start with Deploy your first Azure OpenAI chat model with REST and the SDK. It builds directly on token mechanics from Azure OpenAI tokens, context windows and pricing explained, and your choice of deployment type (Standard vs Global vs Provisioned) from Azure OpenAI deployment types: Standard, Global, Provisioned determines the throughput and price of every tool-calling round-trip. For the auth pattern you will use system-assigned vs user-assigned managed identity, and if your tool is a retrieval call you will likely point it at Azure AI Search.

A quick map of where each moving part lives, so you know which layer owns a failure:

Layer What lives here Who owns it What it can break
Your app The loop, the actual tool code, the schemas App / dev team Wrong loop, stale tool_call_id, bad schema
Azure OpenAI deployment The model, API version, quota (TPM) Platform owner 429 throttling, unsupported feature for old API version
Tool backends The DB/API your functions call Backend team Tool throws → you must feed back an error
Identity (Entra ID) Token to call the endpoint Platform + identity 401 if RBAC/MI missing
Monitoring Token counts, latency, errors Platform / SRE Blind to cost blow-ups without it

Core concepts

Five mental models make every later step obvious.

The model never runs your code — it only asks you to. When you pass a tools array, the model can respond with a tool_calls object naming a function and a JSON string of arguments. That is all it does. It does not execute anything, reach any network, or see any result unless you run the function yourself and feed the output back as a new message. The model is a router that emits intent; your application is the runtime. This separation is the whole security story: the model can only invoke functions you explicitly declared, and you decide whether to honour the call.

A “tool” is a JSON-schema description, not a code reference. You describe each function with a name, a description (this is the prompt the model reads to decide when to call it — write it like documentation), and a parameters object that is a JSON Schema. The model matches the user’s intent against those descriptions. The quality of your description and parameter names directly drives whether the model calls the right tool with the right arguments — a vague description is the single most common cause of “it called the wrong function.”

The conversation is a growing list of messages with roles. Tool use is multi-turn within a single logical request. The message list grows: systemuserassistant (with tool_calls) → tool (your result, tagged with the tool_call_id) → assistant (final answer). Every tool result you append must carry the tool_call_id that the model assigned to that call, or the model cannot match result to request. Get the IDs wrong and you get errors or nonsense.

Structured Outputs constrains the generation, not just validates after. With response_format: { type: "json_schema", json_schema: { ..., strict: true } }, the platform restricts which tokens the model may emit so the output cannot violate the schema. This is constrained decoding — fundamentally different from “ask nicely then validate.” In strict mode you get a hard guarantee: required keys present, no extra keys (additionalProperties:false is mandatory), correct types and enum values. Function arguments can also be made strict (strict:true inside the tool’s function object) so the arguments the model hands you are schema-valid too.

The model can refuse, and that is a first-class outcome. A safety-trained model may decline a request (for example, asked to produce disallowed content in your strict schema). With Structured Outputs the SDK surfaces a refusal field on the message instead of content. You must check for it — a refusal is not an error and not your JSON; treating it as parseable JSON is a bug. Plan the branch.

The vocabulary in one table

Pin down every term before the deep sections. The glossary at the end repeats these for lookup; this table is the mental model side by side.

Term One-line definition Where it appears Why it matters
Tool / function A callable you describe with JSON schema tools array in the request The unit the model can invoke
tool_choice Policy: auto / required / none / named Request body Controls whether & which tool runs
tool_calls The model’s request to call function(s) assistant message in the response What you detect and execute
tool_call_id Unique id linking a call to its result On each call & your tool reply Mis-link → error / wrong mapping
role:"tool" message Your function’s result fed back The follow-up request Closes the loop
response_format Output contract for the final answer Request body json_object or json_schema
strict Constrained decoding to a schema Inside json_schema or a tool Hard guarantee, no drift
additionalProperties Allow keys beyond those declared Inside a JSON schema Must be false for strict
parallel_tool_calls Allow multiple calls in one turn Request body Many tools at once; default on
refusal Model declined the request Response message Branch, don’t parse as JSON
finish_reason Why generation stopped Each choice tool_calls vs stop vs length

The request contract: tools, tool_choice and response_format

Before any code, here is the surface area you are programming against. Three request fields and a few response fields do almost everything.

Declaring tools

A tool is one entry in the tools array. The shape is fixed:

{
  "type": "function",
  "function": {
    "name": "get_order_status",
    "description": "Look up the live status of a customer order by its numeric order ID. Use this whenever the user asks where an order is, when it will arrive, or whether it shipped.",
    "parameters": {
      "type": "object",
      "properties": {
        "order_id": { "type": "integer", "description": "The numeric order ID, e.g. 41982" }
      },
      "required": ["order_id"],
      "additionalProperties": false
    },
    "strict": true
  }
}

Every field earns its place. The breakdown:

Field Required Purpose Gotcha
type Yes Always "function" today Reserved for future tool types
function.name Yes Identifier you match in your switch ^[a-zA-Z0-9_-]{1,64}$; must be unique
function.description Strongly advised The prompt that tells the model when to call it Vague text → wrong-tool calls
function.parameters Yes (object schema) JSON Schema for the arguments Strict mode forbids many keywords
function.strict No (default false) Constrain argument generation to the schema Requires all keys required + additionalProperties:false

Controlling whether a tool runs: tool_choice

tool_choice is the policy knob. It decides whether the model may, must, or must not call a tool on this turn.

tool_choice value Behaviour Use when
"auto" (default when tools present) Model decides: call a tool or answer directly General chat that sometimes needs a tool
"required" Model must call at least one tool You know a tool is needed (e.g. data extraction)
"none" Model may not call any tool; answers in text Temporarily disable tools without removing them
{ "type":"function", "function":{"name":"X"} } Force this specific tool You want the model to call exactly X

A subtle but important point: auto lets the model answer from its own knowledge when it judges a tool unnecessary, which is usually what you want for conversation. For a pure extraction or classification job where you always want structured tool output, set required (or force the named function) so the model can never “chat back” instead.

Shaping the final answer: response_format

response_format constrains the model’s own reply (not a tool’s arguments). Three modes:

response_format What you get Schema enforced? Notes
(omitted) — text Free-form natural language No Default; fine for chat
{ "type": "json_object" } Valid JSON (any shape) No (valid JSON only) “JSON mode” — must also instruct JSON in the prompt
{ "type": "json_schema", "json_schema": { "name":..., "schema":..., "strict": true } } JSON matching your schema Yes, with strict:true Structured Outputs; the strong guarantee

The trap with plain json_object mode: it guarantees parseable JSON but not your JSON — you can still get an unexpected shape, and you must mention “JSON” in your prompt or you risk an error. json_schema with strict:true is the one that gives a real contract. Reach for json_object only on older models that do not support json_schema.

What comes back

The response carries the signals you branch on:

Response field Meaning What you do
choices[0].finish_reason = "tool_calls" The model wants to call tools Execute them, loop
choices[0].finish_reason = "stop" The model gave a final answer Done — read content
choices[0].finish_reason = "length" Hit max_tokens mid-output Raise the limit; output is truncated
message.tool_calls[] The calls (name + JSON args + id) Parse args, run code, feed back
message.content Final text/JSON (null during a tool turn) Render it / parse it
message.refusal The model declined Show a safe message; do not parse

The tool-calling loop, step by step

The single most important thing to internalise is the loop. It is the same five-message dance every time, regardless of language or SDK. Walk it once in plain terms; the lab makes it concrete.

  1. You send the message list plus tools (and a tool_choice). The list starts with a system message and the user turn.
  2. The model responds with either a normal answer (finish_reason: "stop" — you’re done) or a request to call tools (finish_reason: "tool_calls").
  3. You append that assistant message verbatim (including its tool_calls) to your list. This is easy to forget and breaks the loop if you skip it — the model needs to see its own request in the history.
  4. For each tool call, parse the JSON arguments, run the real function, and append a new message with role: "tool", the tool_call_id from that call, and the function’s result serialised as a string in content.
  5. You send the whole list back. Now the model sees the tool result and produces the final natural-language answer (finish_reason: "stop"), which you render.

A few rules that turn this from “works in the demo” to “works at 3 a.m.”:

Rule Why What happens if you ignore it
Append the assistant’s tool_calls message before the tool result The model must see its own call API error or the model re-asks
Every tool message carries the exact tool_call_id Maps result → request Error: “tool_call_id did not match”
Tool content is always a string The API expects text Serialise objects with json.dumps
Handle all calls before re-sending Parallel calls expect all results Model stalls waiting for a missing result
Cap the loop iterations A tool can trigger another tool Runaway loop, runaway bill

Parallel tool calls

By default the model may return several tool calls in one assistant turn — e.g. “compare the weather in Mumbai and Delhi” yields two get_weather calls. They arrive together in tool_calls[], each with its own tool_call_id. You run all of them (ideally concurrently), append one role:"tool" message per call with the matching id, then re-send once. Set parallel_tool_calls: false if your tools must run strictly one at a time (e.g. they mutate shared state and ordering matters) — that forces the model to call them sequentially across turns.

parallel_tool_calls Behaviour Choose when
true (default) Multiple calls can return in one turn Read-only tools; you can fan out
false At most one tool call per turn Tools mutate state; order matters; simpler loop

Structured Outputs and the JSON-Schema subset

Structured Outputs is where most teams trip, because the schema you can use under strict:true is a subset of full JSON Schema. The platform compiles your schema into a constrained-decoding grammar, and that compilation only supports specific keywords. Learn the subset once and you stop fighting “schema not supported” errors.

The non-negotiable rules of strict mode

Rule Requirement Why
Root is an object "type": "object" at the top The grammar needs a keyed root
additionalProperties: false On every object Otherwise extra keys could appear
All properties required List every key in required Strict mode has no truly optional keys
Optional fields → nullable union Use "type": ["string","null"] “Optional” is expressed as may-be-null, still required
Bounded nesting & size Stay within depth/property limits Very large/deep schemas are rejected

The “all keys required” rule surprises everyone. In strict mode you cannot mark a property optional in the usual sense — if a field may be absent, you make it required but allow null as a type (["string","null"]), and your code treats null as “not provided.” This is the correct mental model: the shape is fixed; values may be null.

Which JSON-Schema keywords are supported

A practical cut of what works under strict:true and what to avoid. Treat unsupported keywords as “will error or be ignored — don’t rely on them.”

Keyword Supported under strict Notes
type (object/string/number/integer/boolean/array/null) Yes Unions allowed for nullability
properties + required Yes All keys must be in required
additionalProperties: false Yes (mandatory) Must be present on every object
enum Yes Great for closed value sets
description Yes Helps the model fill fields correctly
items (typed arrays) Yes Element schema follows the same rules
anyOf Yes (for branching shapes) Each branch is itself a strict object
$ref / $defs Yes (for reuse/recursion) Reference within the same schema
minLength / maxLength / pattern Limited / often unsupported Do not depend on string constraints
minimum / maximum / multipleOf Limited / often unsupported Validate numbers in your code
format (email, date-time, …) Not enforced Advisory only; validate yourself
oneOf, not, if/then/else Unsupported Restructure with anyOf/enum

The takeaway: use the schema to lock shape and enums, and enforce value ranges and formats in your own code after parsing. The model gives you the structure; you still own validation of business rules.

Function-argument strict mode vs response strict mode

Two independent places to turn on strict, easy to conflate:

Where Field Constrains Use for
Inside a tool function.strict: true The arguments the model passes to your function Guarantee your function gets well-typed inputs
On the response response_format.json_schema.strict: true The model’s final answer Guarantee the reply is typed data

You can use either, both, or neither. A common solid pattern: strict:true on tools (so your code never parses garbage arguments) and free-text final answers (so the assistant can chat). For a pure extraction endpoint, you often want response_format strict and may not need tools at all.

Architecture at a glance

Trace a single user request from left to right. A user message arrives at your application — a web API, a function, or a chat backend. Your app authenticates to Azure OpenAI using a Microsoft Entra ID token from its managed identity (no key in code), and sends the chat completion with a tools array describing what it can do. The model deployment reasons over the request and, instead of guessing, returns a tool_calls request: “call get_order_status with order_id=41982.” Your app — the orchestration loop — parses those arguments (strict mode guarantees they are typed) and executes the real tool: a query against your order database, a call to Azure AI Search, or any backend API. The tool returns data. Your app appends that result as a role:"tool" message carrying the original tool_call_id and re-sends the conversation.

On the second pass the model has ground truth. It produces the final answer — and if you attached a response_format schema, that answer is schema-valid JSON your UI can render field by field. Throughout, Azure Monitor captures token counts, latency and errors so a chatty loop cannot silently inflate the bill. The numbered hot spots on the diagram are the four places this goes wrong in production: a missing role assignment (401), an unsupported feature on an old API version, a tool that throws (which you must feed back as an error, not crash on), and a schema the platform rejects as too complex.

Left-to-right Azure OpenAI function-calling architecture: a user request enters the app orchestration loop, which authenticates via Entra ID managed identity to an Azure OpenAI model deployment; the model returns tool_calls, the app executes real tools against an order database and Azure AI Search, feeds the tool result back with the matching tool_call_id, and the model returns a schema-valid final answer, with Azure Monitor observing tokens and errors and four numbered failure badges on auth, API version, tool execution and schema validation.

Real-world scenario

Northwind Retail runs a customer-service chatbot for order queries — roughly 12,000 conversations a day. The first version was a plain chat completion with the order data stuffed into the system prompt as “context.” It hallucinated constantly: customers were told orders had shipped when they had not, because the model pattern-matched plausible-sounding statuses. Worse, a nightly batch that “extracted” return reasons from chat transcripts into a warehouse table failed about twice a week when the model wrapped its JSON in a markdown fence or added a comment, breaking the parser and silently dropping that night’s data.

The team re-architected around function calling and structured outputs. They declared three tools: get_order_status(order_id), initiate_return(order_id, reason_code) and search_help_articles(query). tool_choice stayed auto so the bot could still chit-chat (“thanks!”) without a tool. All three tools used strict:true, so order_id always arrived as an integer and reason_code was constrained to an enum of their real return codes — the model could not invent a code. For the nightly extraction job they switched to response_format: json_schema with strict:true and a schema of { order_id, return_reason (enum), sentiment (enum), follow_up_needed (boolean) }. Parse failures went to zero because the output was no longer best-effort JSON; it was constrained to the schema.

Two things bit them in the rollout, both instructive. First, an engineer tested against an older API version and got response_format json_schema is not supported — fixed by pinning a current API version and a current GPT-4o deployment. Second, their initial extraction schema nested return-line-item objects four levels deep with free-form string maps, and the platform rejected it as too complex; flattening it (and replacing the open string map with a typed array and enums) made it compile. After that, the initiate_return tool got an extra guard: because a tool call is just intent, the app required a human confirmation step before actually executing the return — the model could propose a refund, but only a confirmed click executed it. That last point is the security lesson the whole team internalised: the model asking to call a function is never authorisation to perform it.

The numbers after a month: hallucinated order statuses dropped to effectively zero (the bot now either has real data or says it cannot find the order), the extraction job ran 30 nights without a parse failure, and average tokens per conversation rose about 18% (tool definitions and the extra round-trip are not free) — a cost they happily paid for correctness, and later trimmed by pruning verbose tool descriptions and dropping unused tools from low-intent flows.

Advantages and disadvantages

The trade-off is real and worth stating plainly before you commit.

Advantages Disadvantages
Model output becomes a contract you can parse safely More tokens per request (tool defs + round-trips)
Eliminates a class of hallucinations (data comes from your code) Added latency: at least two model calls per tool use
Strict mode guarantees schema-valid JSON — no defensive parser Strict schema is a subset of JSON Schema (learning curve)
enums stop the model inventing codes/categories A tool that throws still needs explicit error feed-back
Clean separation: model routes, your code is source of truth Loop bugs (IDs, ordering) are subtle and easy to introduce
Parallel tool calls fan out reads efficiently Runaway loops can inflate cost if you forget an iteration cap

When the advantages dominate: any time the output feeds another system, any time the model must use live or private data, and any data-extraction or classification job where shape must be guaranteed. When the disadvantages matter most: ultra-latency-sensitive single-turn chat where a second round-trip hurts, and very high-volume flows where the extra tokens move the bill — there, prune tools aggressively and consider a smaller model (GPT-4o mini) for the routing step. For the throughput/latency profile of each option see Azure OpenAI deployment types: Standard, Global, Provisioned.

Hands-on lab

This is the centrepiece. You will stand up an Azure OpenAI resource and a tool-capable model deployment, then call it three ways — the portal playground to see a tool call, the az CLI + a small Python loop to build the real call → execute → feed-back cycle, and a Bicep template for the infrastructure. You will reproduce a hallucination, fix it with a tool, force a structured output, and validate each step. Everything is small and you delete it at the end.

Region note: Azure OpenAI model availability varies by region. This lab uses eastus, which broadly carries the GPT-4o family; if your subscription’s quota lives elsewhere, substitute your region. Run the CLI parts in Cloud Shell (Bash).

Part A — Provision the resource and a deployment (az CLI)

Step 1 — Variables and resource group.

RG=rg-aoai-fc-lab
LOC=eastus
AOAI=aoai-fc-$RANDOM        # globally-unique resource name
DEPLOY=gpt4o-tools          # your deployment name (you choose this)
MODEL=gpt-4o               # model; version set below
az group create -n $RG -l $LOC -o table

Expected: a table row showing provisioningState = Succeeded.

Step 2 — Create the Azure OpenAI account (kind OpenAI).

az cognitiveservices account create \
  -n $AOAI -g $RG -l $LOC \
  --kind OpenAI --sku S0 \
  --custom-domain $AOAI \
  --yes -o table

Expected: an account row, kind = OpenAI, provisioningState = Succeeded. The --custom-domain gives you a stable https://$AOAI.openai.azure.com/ endpoint (also required for Entra ID token auth later).

Step 3 — Deploy a tool-capable model. Pick a model version your region offers; list them first if unsure.

# See available GPT-4o versions in this region/subscription
az cognitiveservices account list-models -n $AOAI -g $RG \
  --query "[?contains(name,'gpt-4o')].{name:name, version:version}" -o table

# Create the deployment (substitute a version the list shows)
az cognitiveservices account deployment create \
  -n $AOAI -g $RG \
  --deployment-name $DEPLOY \
  --model-name $MODEL --model-version "2024-08-06" \
  --model-format OpenAI \
  --sku-name Standard --sku-capacity 10 -o table

Expected: a deployment row with provisioningState = Succeeded. --sku-capacity 10 means 10K tokens-per-minute (TPM) of quota — plenty for the lab. If you get a quota error, lower the capacity or pick a region where you have quota.

Step 4 — Capture the endpoint and (for now) a key. You will switch to managed identity in Part D; a key keeps the first call simple.

ENDPOINT=$(az cognitiveservices account show -n $AOAI -g $RG --query properties.endpoint -o tsv)
KEY=$(az cognitiveservices account keys list -n $AOAI -g $RG --query key1 -o tsv)
echo "Endpoint: $ENDPOINT"
API_VERSION=2024-10-21   # a version that supports tools + json_schema

Expected: the endpoint prints as https://aoai-fc-xxxxx.openai.azure.com/. Keep API_VERSION current — an old one is the number-one cause of “feature not supported.”

Part B — See a tool call (and a structured output) in the portal

Step 5 — Open the playground. In the Azure portal, go to your Azure OpenAI resource → Go to Azure AI Foundry portalChat playground. Select the gpt4o-tools deployment.

Step 6 — Reproduce the hallucination. With no tools defined, ask: “Where is order 41982?” The model invents a status — note how confident and wrong it is. This is the problem you are about to fix.

Step 7 — Add a tool. In the playground’s Tools (or Functions) panel, add a function definition:

{
  "type": "function",
  "function": {
    "name": "get_order_status",
    "description": "Look up the live status of an order by its numeric ID.",
    "parameters": {
      "type": "object",
      "properties": { "order_id": { "type": "integer" } },
      "required": ["order_id"],
      "additionalProperties": false
    },
    "strict": true
  }
}

Ask again: “Where is order 41982?” This time the playground shows the model requesting a tool callget_order_status with {"order_id": 41982} — and pauses. The playground does not run your code; it shows you the tool_calls payload. That payload is exactly what your app receives. Paste a fake result back (the playground lets you supply a tool response) and watch the model phrase a grounded answer.

Step 8 — Force a structured output. In the playground’s response format control, switch to JSON schema and paste a small schema (object with status enum and eta string, additionalProperties:false, both required). Ask a question that yields that shape and confirm the reply is strict JSON. You have now seen both features without writing a line of code. The matrix of what the playground demonstrates:

Playground step What you observe Real-world equivalent
6 (no tools) Confident hallucination The bug you are replacing
7 (tool added) tool_calls payload, model pauses What your app receives to execute
7 (paste result) Grounded final answer The feed-back step in your loop
8 (json schema) Strict-typed reply Structured Outputs in production

Part C — Build the real loop (Python)

Now the actual implementation. Install the SDK and write the five-message loop with a real (mock) tool.

Step 9 — Install the SDK in Cloud Shell.

pip install --quiet "openai>=1.40" azure-identity

Step 10 — Write the tool and the loop. Create fc.py. The tool is mocked (a dict lookup) so the lab needs no database — in production this function body is your real DB/API call.

import os, json
from openai import AzureOpenAI

client = AzureOpenAI(
    azure_endpoint=os.environ["ENDPOINT"],
    api_key=os.environ["KEY"],
    api_version=os.environ["API_VERSION"],
)
DEPLOY = os.environ["DEPLOY"]

# --- the real tool (mocked here) ---
ORDERS = {41982: {"status": "in_transit", "eta": "2026-06-27"}}
def get_order_status(order_id: int) -> dict:
    return ORDERS.get(order_id, {"status": "not_found", "eta": None})

tools = [{
    "type": "function",
    "function": {
        "name": "get_order_status",
        "description": "Look up the live status of an order by its numeric ID. "
                       "Use whenever the user asks where an order is or when it arrives.",
        "parameters": {
            "type": "object",
            "properties": {"order_id": {"type": "integer",
                            "description": "Numeric order ID, e.g. 41982"}},
            "required": ["order_id"],
            "additionalProperties": False,
        },
        "strict": True,
    },
}]

messages = [
    {"role": "system", "content": "You are Northwind's order assistant. "
                                   "Never guess order status; always use the tool."},
    {"role": "user", "content": "Where is order 41982 and when will it arrive?"},
]

for _ in range(5):  # hard cap on loop iterations
    resp = client.chat.completions.create(
        model=DEPLOY, messages=messages, tools=tools, tool_choice="auto",
    )
    msg = resp.choices[0].message
    if not msg.tool_calls:
        print("ASSISTANT:", msg.content)
        break
    # 1) append the assistant's tool-call message verbatim
    messages.append(msg)
    # 2) run each tool, append a result tagged with its tool_call_id
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)  # strict => valid
        result = get_order_status(**args)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(result),
        })
    # loop re-sends with the tool result included

Step 11 — Run it.

ENDPOINT=$ENDPOINT KEY=$KEY API_VERSION=$API_VERSION DEPLOY=$DEPLOY python fc.py

Expected output: a grounded sentence such as “Order 41982 is in transit and is expected to arrive on 27 June 2026.” If you ask about an unknown order, you get the honest not_found answer — no hallucination. You just built the full loop.

Step 12 — Add a Structured Output for extraction. Append a second script extract.py that uses response_format instead of (or alongside) tools — the classic “give me typed data” pattern:

import os, json
from openai import AzureOpenAI
client = AzureOpenAI(azure_endpoint=os.environ["ENDPOINT"], api_key=os.environ["KEY"],
                     api_version=os.environ["API_VERSION"])

schema = {
    "name": "return_record",
    "strict": True,
    "schema": {
        "type": "object",
        "properties": {
            "order_id": {"type": "integer"},
            "reason": {"type": "string",
                       "enum": ["damaged", "wrong_item", "no_longer_needed", "late"]},
            "sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]},
            "follow_up_needed": {"type": "boolean"},
        },
        "required": ["order_id", "reason", "sentiment", "follow_up_needed"],
        "additionalProperties": False,
    },
}

resp = client.chat.completions.create(
    model=os.environ["DEPLOY"],
    messages=[
        {"role": "system", "content": "Extract the return record from the message."},
        {"role": "user", "content":
            "Order 41982 arrived smashed to bits, I'm furious, I want a refund now."},
    ],
    response_format={"type": "json_schema", "json_schema": schema},
)
msg = resp.choices[0].message
if msg.refusal:                       # always check first
    print("REFUSED:", msg.refusal)
else:
    print(json.loads(msg.content))    # guaranteed to match the schema

Run it:

ENDPOINT=$ENDPOINT KEY=$KEY API_VERSION=$API_VERSION DEPLOY=$DEPLOY python extract.py

Expected: a dict exactly matching the schema, e.g. {'order_id': 41982, 'reason': 'damaged', 'sentiment': 'negative', 'follow_up_needed': True}. reason can only be one of your four codes — the model cannot invent one. That is constrained decoding doing its job.

Part D — The infrastructure as Bicep (and managed identity)

Step 13 — Author the Bicep. Create aoai.bicep. It provisions the account, a deployment, and grants a Web App’s system-assigned identity the data-plane role so production code uses no keys.

param location string = resourceGroup().location
param aoaiName string
param deploymentName string = 'gpt4o-tools'
@description('Principal ID of the app that will call the model (e.g. a Web App MI)')
param callerPrincipalId string

resource aoai 'Microsoft.CognitiveServices/accounts@2024-10-01' = {
  name: aoaiName
  location: location
  kind: 'OpenAI'
  sku: { name: 'S0' }
  properties: {
    customSubDomainName: aoaiName
    publicNetworkAccess: 'Enabled'   // lock down with private endpoint in prod
  }
}

resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2024-10-01' = {
  parent: aoai
  name: deploymentName
  sku: { name: 'Standard', capacity: 10 }   // 10K TPM
  properties: {
    model: { format: 'OpenAI', name: 'gpt-4o', version: '2024-08-06' }
  }
}

// Built-in role: Cognitive Services OpenAI User (data-plane inference)
var openAiUserRoleId = '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd'
resource roleAssign 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(aoai.id, callerPrincipalId, openAiUserRoleId)
  scope: aoai
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', openAiUserRoleId)
    principalId: callerPrincipalId
    principalType: 'ServicePrincipal'
  }
}

output endpoint string = aoai.properties.endpoint

Step 14 — Deploy the Bicep. Pass the principal ID of whatever identity will call the model (for the lab you can use your own user object id to test keyless from Cloud Shell).

MY_OID=$(az ad signed-in-user show --query id -o tsv)
az deployment group create -g $RG \
  --template-file aoai.bicep \
  --parameters aoaiName=aoaibicep$RANDOM callerPrincipalId=$MY_OID -o table

Expected: provisioningState = Succeeded and an endpoint output. Role assignments can take a minute or two to propagate.

Step 15 — Call it keyless with managed identity / Entra token. Swap the SDK auth from a key to a token provider. No secret in code:

from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from openai import AzureOpenAI

token_provider = get_bearer_token_provider(
    DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default")
client = AzureOpenAI(
    azure_endpoint=os.environ["ENDPOINT"],
    azure_ad_token_provider=token_provider,
    api_version=os.environ["API_VERSION"],
)

Re-run fc.py with this client (drop the KEY env var). Expected: identical grounded answer, but now authenticated via Entra ID — the production-grade pattern. If you get a 401, the role assignment has not propagated or the principal lacks Cognitive Services OpenAI User; wait and retry, then verify the assignment. The auth options compared:

Auth method Secret in code? Setup Use when
API key Yes (a secret to rotate/leak) Zero Quick local test only
Entra ID + managed identity No Assign Cognitive Services OpenAI User All production workloads
Entra ID + user credential No Your account needs the role Keyless local dev

Step 16 — Validation checklist

You should now be able to tick every box:

Check How to confirm Expected
Deployment is tool-capable Playground showed a tool_calls payload Tool call rendered
The loop closes correctly fc.py returns a grounded answer Real order data, no hallucination
Unknown input is honest Ask about a missing order not_found, not invented
Structured Output is enforced extract.py prints a schema-shaped dict All keys present, enum respected
Refusals are handled refusal branch exists No crash on a declined request
Keyless auth works Re-run with the token provider Same answer, no key

Step 17 — Teardown

az group delete -n $RG --yes --no-wait

Expected: the command returns immediately; the resource group and everything in it (account, deployments, role assignment) are removed in the background. There is no lingering charge once the account is gone — Standard deployments bill per token, so with no traffic the cost was only the few requests you made (well under ₹50 for the whole lab).

Common mistakes & troubleshooting

These are the failure modes that actually show up. Symptom → root cause → how to confirm → fix.

# Symptom Root cause Confirm Fix
1 response_format json_schema is not supported API version or model too old Print the API version; check model Pin a current API version + GPT-4o family deployment
2 Invalid schema ... additionalProperties must be false Strict schema missing the rule Read the error; inspect the schema Add additionalProperties:false to every object
3 ... is not in 'required' under strict A property left out of required Compare properties vs required List every key in required; nullable for optional
4 Schema rejected as too complex Too deep/large for the grammar Note the depth/property count Flatten nesting; replace open string maps with typed arrays + enums
5 tool_call_id did not match / loop errors Result fed back with wrong/missing id, or assistant message not appended Inspect your message list order Append assistant tool_calls first; copy the exact tool_call_id
6 Model answers in prose instead of calling the tool tool_choice:auto and weak description Read the tool description Sharpen the description; or set tool_choice:"required"/named
7 Model calls the wrong tool Overlapping/vague descriptions Two tools sound similar Disambiguate descriptions; remove unused tools
8 Model invents a category/code No enum constraint Output has off-list values Add an enum to that property (strict)
9 finish_reason: "length", truncated JSON max_tokens too low for the output Check finish_reason Raise max_tokens; shrink the schema
10 401 Unauthorized on keyless call Missing role / not propagated List role assignments on the account Assign Cognitive Services OpenAI User; wait, retry
11 429 Too Many Requests TPM/RPM quota exceeded Check rate-limit headers / quota Raise deployment capacity; add ret/backoff; use Global/PTU
12 Crash when a tool throws You didn’t feed back an error result Tool exception bubbles up Catch it; append role:"tool" with an error string for the model to handle
13 Runaway cost / infinite tool loop A tool result triggers another call forever Token usage spikes; same call repeats Cap loop iterations (e.g. 5); log each turn
14 refusal content parsed as JSON Didn’t check the refusal field json.loads throws on a refusal Check message.refusal before parsing content
15 Parallel tools stall Re-sent before all results appended Some tool_call_ids have no reply Append a result for every call before re-sending

A few decision shortcuts to keep on hand:

If you see… It’s probably… Do this
Confident but wrong facts No tool / model answering from training data Add a tool; set tool_choice so it must use it for data
Intermittent parse failures Best-effort JSON, not strict Move to response_format: json_schema, strict:true
“not supported” errors Stale API version Pin a current API version
Off-list values Open string field Constrain with enum
Costs creeping up Verbose tool defs / extra round-trips Prune descriptions, drop unused tools, cap loops

Best practices

Security notes

Tool use widens the blast radius because the model now influences which of your functions run — so the controls are about constraining what can happen and who is calling.

Cost & sizing

Function calling and structured outputs are billed the same way as any chat completion — per token, input + output — but the pattern changes your token profile in three ways, and that is what to size for.

Cost driver Effect on tokens How to control
Tool definitions Sent as input on every request Keep descriptions tight; send only relevant tools
The feed-back round-trip A second (or third) full call per tool use Cache where possible; cap loop iterations
Tool results in the history Large results inflate input on the next turn Return only the fields the model needs
Schema complexity Bigger schemas add input tokens Flatten; reuse with $ref
Parallel tool calls Several results appended at once Trim each result’s payload

For the per-1K-token rates and how context windows bound all of this, see Azure OpenAI tokens, context windows and pricing explained. Sizing guidance:

Knob Option When to choose Cost note
Deployment type Standard (pay-per-token) Variable/spiky traffic Pay only for tokens used
Deployment type Global Standard Best price-throughput, no data-residency need Often cheapest per token
Deployment type Provisioned (PTU) High, steady volume; latency SLA Fixed monthly; predictable
Capacity (TPM) Raise on 429s Throttling under load Quota, not direct charge
Routing model GPT-4o mini High-volume tool routing Fraction of GPT-4o cost

Rough mental model: a tool-using conversation typically costs 1.5–2× a single-turn answer because of the tool definitions plus the extra round-trip. At 12,000 conversations/day on a mini-class model that is small money; on a flagship model with verbose tools it adds up — which is why pruning tool descriptions and using a smaller routing model are the two highest-leverage cost levers. Standard deployments have no idle charge: with no traffic, you pay nothing, so a dev resource left running between sessions costs only the requests you make. Choose Provisioned only once steady volume justifies the fixed commitment — see Azure OpenAI deployment types.

Interview & exam questions

1. What is function calling in Azure OpenAI, and what does the model actually do? You pass a tools array describing callable functions with JSON-schema parameters. The model decides which function to call and with what arguments and returns that intent as tool_calls — it does not execute anything. Your application runs the function and feeds the result back for the model to phrase. Maps to AI-102.

2. How does tool_choice differ from response_format? tool_choice controls whether and which tool the model may call (auto/required/none/named). response_format controls the shape of the model’s own final answer (text, json_object, or schema-validated json_schema). One governs tool invocation; the other governs the reply’s structure.

3. What does strict: true guarantee, and what must the schema include? It enables constrained decoding so output cannot violate the schema. The schema must have additionalProperties:false on every object and list every property in required; optional fields are expressed as nullable type unions. It guarantees required keys, no extra keys, and correct types/enums.

4. Walk through the tool-calling loop. Send messages + tools → model returns tool_calls (finish_reason:"tool_calls") → append that assistant message → run each function and append a role:"tool" message with the matching tool_call_id → re-send → model returns the final answer (finish_reason:"stop"). The tool_call_id linkage and appending the assistant message are the two easy-to-miss steps.

5. Why is appending the assistant’s tool-call message before the tool result necessary? The model needs its own request in the conversation history to associate the incoming tool result with the call it made. Skipping it causes an API error or makes the model re-issue the call, breaking the loop.

6. How do you handle parallel tool calls? The model may return several calls in one turn, each with its own tool_call_id. Execute all of them (concurrently if safe), append one role:"tool" result per call with the correct id, then re-send once. Set parallel_tool_calls:false if tools mutate shared state and ordering matters.

7. What is a model refusal and how do you handle it? A safety-trained model can decline a request; with Structured Outputs the message carries a refusal field instead of content. It is a valid outcome, not an error — branch on it and show a safe message; never feed a refusal into json.loads.

8. Name three JSON-Schema keywords that are not reliably supported under strict mode. oneOf, if/then/else/not, and numeric/string constraints like minimum/maximum/pattern/format are not reliably enforced. Lock shape and enums in the schema; validate ranges and formats in your own code afterward.

9. How should you authenticate a production app to Azure OpenAI? With Microsoft Entra ID using the app’s managed identity, granted the Cognitive Services OpenAI User role on the resource (data-plane inference). Avoid API keys; if unavoidable, keep them in Key Vault, never in code.

10. Why might response_format: json_schema return “not supported,” and how do you fix it? The API version or model deployment is too old. Pin a current API version (e.g. 2024-10-21 or later) and use a current GPT-4o/GPT-4.1 family deployment. The single most common cause is a stale API version copied from an old sample.

11. How does function calling reduce hallucinations? It moves the source of truth from the model’s training data to your code. Instead of guessing an answer, the model requests a function call; your function returns real data, which the model then phrases. For unknown inputs the function returns “not found,” so the model says it cannot find the item rather than inventing one.

12. What are the main cost implications of tool use, and how do you control them? Tool definitions are sent on every request, each tool use adds a round-trip, and tool results inflate later input. Control cost by keeping descriptions tight, sending only relevant tools, returning minimal result fields, capping loop iterations, and using a smaller routing model (GPT-4o mini).

Quick check

  1. The model returns finish_reason: "tool_calls". What two things must you append to the message list before re-sending, and in what order?
  2. Under strict mode, how do you express a field that may be absent, given that all keys must be required?
  3. Which request field forces the model to call a specific named tool, and what value do you pass?
  4. You get response_format json_schema is not supported. What is the most likely cause?
  5. A tool you call throws an exception. What should your loop do instead of crashing?

Answers

  1. First append the assistant message containing the tool_calls (verbatim), then for each call append a role:"tool" message carrying that call’s tool_call_id and the result. Order matters: assistant message first, then the tool results.
  2. Make the field required but give it a nullable type union (e.g. "type": ["string","null"]); your code treats null as “not provided.” Strict mode has no truly optional keys — the shape is fixed, values may be null.
  3. tool_choice, set to {"type":"function","function":{"name":"your_tool"}}. ("required" forces some tool; the named form forces that tool.)
  4. A stale API version (or a model deployment that doesn’t support json_schema). Pin a current API version and a current GPT-4o-family deployment.
  5. Catch the exception and append a role:"tool" message with the matching tool_call_id whose content is an error string, so the model can apologise or try an alternative. Never let the tool exception bubble up and crash the request.

Glossary

Next steps

Azure OpenAIFunction CallingTool UseStructured OutputsJSON SchemaAgentsAI FoundryLLM
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading