Deep dive · Python

Foundry SDK deep dive

AI-103 rarely asks you to write code from scratch, but it regularly shows you a snippet and asks what it does, what is missing, or what is wrong. This page collects the shapes worth recognising on sight.

How to use this page

Read for shape, not syntax. For each block, ask yourself: which client is this, what would break if a line were removed, and which permission does it need? Those are the three angles the exam takes. Every sample here uses azure-ai-projects 2.x against a single project endpoint.

Setup and authentication

Install
# One package for the platform, one for identity.
pip install "azure-ai-projects>=2.3.0" azure-identity

# Optional, depending on what you touch:
pip install azure-ai-evaluation          # evaluators
pip install azure-search-documents       # Azure AI Search
pip install azure-cognitiveservices-speech
pip install azure-monitor-opentelemetry  # tracing

# Authenticate locally before running anything.
az login
Python · client
import os
from azure.identity import DefaultAzureCredential
from azure.ai.projects import AIProjectClient

PROJECT_ENDPOINT = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
# https://<resource-name>.services.ai.azure.com/api/projects/<project-name>

project = AIProjectClient(
    endpoint=PROJECT_ENDPOINT,
    credential=DefaultAzureCredential(),
)

openai = project.get_openai_client()
flowchart TB
  CRED["DefaultAzureCredential"] --> PC["AIProjectClient(endpoint, credential)"]
  PC --> OA["get_openai_client()<br/>OpenAI-compatible surface"]
  PC --> AG["project.agents<br/>create_version, list, get, delete"]
  PC --> TEL["project.telemetry<br/>Application Insights connection"]
  PC --> DS["datasets · indexes · evaluations<br/>connections · deployments"]

  OA --> RESP["responses.create / stream / parse"]
  OA --> CONV["conversations.create"]
  OA --> FILES["files · vector_stores"]
  OA --> IMG["images.generate / edit"]
  OA --> EMB["embeddings.create"]
What hangs off AIProjectClient. Most exam snippets are somewhere on this tree.

Four ways a setup snippet is made wrong

  1. The endpoint is the resource endpoint rather than the project endpoint — no /api/projects/<project> segment.
  2. azure-ai-projects 1.x code (classic) mixed with 2.x — the two are not compatible.
  3. An API key where the scenario says keyless — it should be DefaultAzureCredential.
  4. Credentials are fine but the caller has no Foundry User role, so a valid token is still refused.

The Responses API

Python · responses
# 1. Simplest call
r = openai.responses.create(model="gpt-5-mini", input="Define RAG in one sentence.")
print(r.output_text)

# 2. With roles, when you need a system instruction
r = openai.responses.create(
    model="gpt-5-mini",
    input=[
        {"role": "system", "content": "You answer only about Azure. Be concise."},
        {"role": "user", "content": "What is a Foundry project?"},
    ],
)

# 3. Chained turns without a conversation object
first = openai.responses.create(model="gpt-5-mini", input="Summarise this contract.")
second = openai.responses.create(
    model="gpt-5-mini",
    previous_response_id=first.id,
    input="Now list the three biggest risks.",
)

# 4. Multi-turn with server-side state
conversation = openai.conversations.create()
openai.responses.create(conversation=conversation.id, model="gpt-5-mini", input="Book a room in Lisbon.")
openai.responses.create(conversation=conversation.id, model="gpt-5-mini", input="Make it two nights.")

# 5. Tuning parameters
r = openai.responses.create(
    model="gpt-5-mini",
    input=prompt,
    temperature=0,            # deterministic-ish; do not also tune top_p
    max_output_tokens=800,    # also feeds the TPM rate-limit estimate
)
NeedUse
One-shot callresponses.create(model=..., input="...")
Follow-up without server stateprevious_response_id=
Full multi-turn sessionconversations.create(), then pass conversation=
Typed outputresponses.parse(..., text_format=Model)
Token-by-token outputresponses.stream(...)
Talk to a named agentproject.get_openai_client(agent_name="...")

Streaming

Python · streaming
# Streaming improves perceived latency; the event types matter.
with openai.responses.stream(model="gpt-5-mini", input="Write a haiku about Azure.") as stream:
    for event in stream:
        if event.type == "response.output_text.delta":
            print(event.delta, end="", flush=True)
        elif event.type == "response.error":
            raise RuntimeError(event.error)

    final = stream.get_final_response()

print()
print(final.usage.input_tokens, final.usage.output_tokens)

Structured outputs

Python · structured output
from pydantic import BaseModel
from typing import Literal

class Extraction(BaseModel):
    customer: str
    order_id: str
    issue_type: Literal["delivery", "billing", "quality", "other"]
    urgent: bool

parsed = openai.responses.parse(
    model="gpt-5-mini",
    input=[{"role": "user", "content": email_text}],
    text_format=Extraction,       # constrains generation to the schema
)

record = parsed.output_parsed     # a typed Extraction, not a string
print(record.issue_type, record.urgent)

Tools

The function-calling loop

The single most important code shape in the exam. The model decides; your code executes; you feed the result back keyed by call_id. Missing the loop, or missing the call_id, are the two classic broken snippets.

Python · function calling
import json

TOOLS = [{
    "type": "function",
    "name": "get_order_status",
    "description": "Look up the delivery status of an order by its id.",
    "parameters": {
        "type": "object",
        "properties": {"order_id": {"type": "string"}},
        "required": ["order_id"],
        "additionalProperties": False,
    },
}]

def get_order_status(order_id: str) -> dict:
    return {"order_id": order_id, "status": "in transit", "eta": "2026-09-19"}

response = openai.responses.create(
    model="gpt-5-mini",
    input="Where is order A-4471?",
    tools=TOOLS,
)

# The model returns tool calls; YOUR code executes them and feeds results back.
# Loop, because one turn can produce several calls or chain into more.
while True:
    calls = [item for item in response.output if item.type == "function_call"]
    if not calls:
        break

    outputs = []
    for call in calls:
        args = json.loads(call.arguments)
        result = get_order_status(**args)
        outputs.append({
            "type": "function_call_output",
            "call_id": call.call_id,        # must match the call
            "output": json.dumps(result),
        })

    response = openai.responses.create(
        model="gpt-5-mini",
        previous_response_id=response.id,
        input=outputs,
        tools=TOOLS,
    )

print(response.output_text)

Three failure modes in that loop

  • No loop. Handling only the first round means chained or parallel tool calls are dropped and the model never produces a final answer.
  • Missing call_id. The service cannot match the output to the call, and the model sees an unanswered tool call.
  • Tools omitted on the follow-up call. Pass tools= again so the model can still call them on the next round.

Built-in tools

Python · built-in tools
# File search over a Foundry-managed vector store
file = openai.files.create(file=open("handbook.pdf", "rb"), purpose="assistants")
store = openai.vector_stores.create(name="hr-handbook", file_ids=[file.id])

r = openai.responses.create(
    model="gpt-5-mini",
    input="How many days of parental leave do contractors get?",
    tools=[{"type": "file_search", "vector_store_ids": [store.id]}],
)

# Code interpreter: the model writes and runs code in a sandbox
r = openai.responses.create(
    model="gpt-5-mini",
    input="Chart the monthly totals in this CSV and describe the trend.",
    tools=[{"type": "code_interpreter"}],
)

# A remote MCP server as a tool
r = openai.responses.create(
    model="gpt-5-mini",
    input="Which work items are assigned to me and still open?",
    tools=[{
        "type": "mcp",
        "server_label": "azure-devops",
        "server_url": "https://example.com/mcp",
        "require_approval": "always",     # human gate before tool execution
    }],
)

Agent lifecycle

Python · agents
from azure.ai.projects.models import PromptAgentDefinition

# Create (or update) an agent. Each call produces a new immutable VERSION.
agent = project.agents.create_version(
    agent_name="support-triage",
    definition=PromptAgentDefinition(
        model="gpt-5-mini",
        instructions=(
            "Triage incoming support requests. Classify severity, identify the "
            "product area, and create a ticket only after confirming with the user."
        ),
        tools=[{"type": "file_search", "vector_store_ids": [store.id]}],
    ),
)
print(agent.id, agent.name, agent.version)

# Talk to it: get a client pre-bound to the agent.
agent_openai = project.get_openai_client(agent_name="support-triage")
conversation = agent_openai.conversations.create()

reply = agent_openai.responses.create(
    conversation=conversation.id,
    input="Checkout has been throwing 500s for twenty minutes.",
)
print(reply.output_text)

# Housekeeping
for version in project.agents.list_versions(agent_name="support-triage"):
    print(version.version, version.created_at)

Reading agent code correctly

  • create_version is both create and update: every call snapshots a new immutable version, which is what makes rollback and comparison possible.
  • The agent is addressed by name, not id, when binding a client to it.
  • A prompt agent is a PromptAgentDefinition. A hosted agent ships your container or .zip instead and calls the project endpoint from inside.
  • Calling the Responses API directly with inline instructions and tools and no agent resource is an ephemeral agent.

Evaluation

Python · evaluation
from azure.ai.evaluation import (
    GroundednessEvaluator, RelevanceEvaluator, CoherenceEvaluator, evaluate,
)

judge = {"azure_endpoint": AZURE_ENDPOINT, "azure_deployment": "gpt-5-mini"}

results = evaluate(
    data="golden.jsonl",   # rows of {query, context, response, ground_truth}
    evaluators={
        "groundedness": GroundednessEvaluator(judge),
        "relevance": RelevanceEvaluator(judge),
        "coherence": CoherenceEvaluator(judge),
    },
    azure_ai_project=PROJECT_ENDPOINT,   # publish results to the portal
    output_path="./eval_results.json",
)

print(results["metrics"])
assert results["metrics"]["groundedness.groundedness"] >= 4.0   # CI gate

The data file is JSON Lines with the columns each evaluator needs — typically query, context, response and sometimes ground_truth. AI-assisted evaluators need a judge model configured. Passing azure_ai_project publishes the run to the portal so results are visible alongside traces.

Tracing

Python · tracing
import os
from azure.monitor.opentelemetry import configure_azure_monitor

connection_string = project.telemetry.get_application_insights_connection_string()
configure_azure_monitor(connection_string=connection_string)

# Prompt and completion text is NOT recorded by default: it can contain
# customer data. Opt in deliberately, and only where retention allows.
os.environ["AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED"] = "true"

# From here, model calls, tool calls and agent decisions appear as spans in
# Application Insights and in the Foundry portal's tracing view.

Content recording is off by default — deliberately

Traces capture the call tree without prompt and completion text unless you opt in. That default exists because prompts routinely contain customer data. If a question asks why traces show spans but no prompt text, the answer is the content-recording setting — and the follow-up consideration is whether your retention policy permits it.

Azure AI Search from Python

Python · search
from azure.identity import DefaultAzureCredential
from azure.search.documents import SearchClient
from azure.search.documents.models import VectorizableTextQuery

client = SearchClient(
    endpoint="https://my-search.search.windows.net",
    index_name="policies",
    credential=DefaultAzureCredential(),   # keyless; needs a Search data-plane role
)

results = client.search(
    search_text=question,                            # BM25
    vector_queries=[VectorizableTextQuery(           # ANN, embedded by the index vectorizer
        text=question, k_nearest_neighbors=50, fields="content_vector",
    )],
    query_type="semantic",                           # L2 rerank
    semantic_configuration_name="default",
    query_caption="extractive",
    filter="department eq 'HR'",                     # text fields only
    top=5,
)

What to look for in a search snippet

  • search_text alone = keyword only. vector_queries alone = vector only. Both = hybrid, merged with RRF.
  • query_type="semantic" plus a semantic_configuration_name = semantic reranking. Without the configuration it fails.
  • VectorizableTextQuery means the index has a vectorizer and embeds the query for you; VectorizedQuery means you supplied the vector yourself.
  • filter applies to text fields only.
  • Keyless search needs a data-plane role on the search service, not just a control-plane one.

Classic to new: the migration map

Older samples, blog posts and practice questions use the classic shapes. Recognise them and know the replacement.

Classic (Agents v1 / Assistants)Current (Agents v2 / Responses)
AzureOpenAI(api_key=..., api_version="2024-xx-xx")AIProjectClient(endpoint, credential).get_openai_client()
client.beta.assistants.create(...)project.agents.create_version(agent_name, definition=PromptAgentDefinition(...))
client.beta.threads.create()openai.conversations.create()
threads.messages.create(...)Conversation items, or just input= on the response
threads.runs.create_and_poll(...)openai.responses.create(conversation=...) — no polling loop
chat.completions.create(messages=[...])responses.create(input=[...]) (chat completions still exists for plain model calls)
run.required_action.submit_tool_outputsfunction_call_output items with a call_id
Monthly api-version query parametersStable /openai/v1/ routes
azure-ai-inference, azure-ai-generative, azure-ai-mlazure-ai-projects 2.x

A three-second triage for any snippet

See threads, runs, assistants, submit_tool_outputs or an api_version string? It is classic. See AIProjectClient, responses, conversations or create_version? It is current. If a question asks you to modernise code, that mapping is the whole answer.

Where to go next

Pair this page with Skill 2 for the concepts behind each call, and finish with Exam tips & caveats for the numbers and traps worth drilling the night before.