Calling models from code
Export PDFFoundry speaks the OpenAI API shape, so almost everything you know about calling OpenAI applies. This lesson shows the two ways to get a client (through the Foundry SDK, or directly with the OpenAI SDK), explains the Responses and Chat Completions APIs, and covers multi-turn chat, streaming, structured output, embeddings, the errors you will see, and a retry strategy that respects rate limits.
Which SDK should you use?
| SDK | Package | Use it when |
|---|---|---|
| Foundry SDK | azure-ai-projects | You use agents, evaluations, datasets, connections, or tracing. It also hands you a ready OpenAI client. |
| OpenAI SDK | openai | You only need model inference, want the lowest latency, need embeddings, or are porting existing OpenAI code. |
| Anthropic SDK | anthropic | You deployed Claude models in Foundry. |
| Agent Framework | agent-framework | You orchestrate multi-agent systems in code (covered briefly in the Agent Service lesson). |
Most apps use the Foundry SDK and call get_openai_client() on it. You get one authenticated client for both worlds.
Option 1: through the Foundry SDK
This example calls your Azure resources, so it needs a deployed project and a signed-in identity. Output will differ on every run.
import os
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
load_dotenv() # reads FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL_DEPLOYMENT from .env
project = AIProjectClient(
endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
credential=DefaultAzureCredential(),
)
with project.get_openai_client() as client:
response = client.responses.create(
model=os.environ["FOUNDRY_MODEL_DEPLOYMENT"], # deployment name, e.g. "gpt-5-mini"
instructions="You are a concise assistant for Azure engineers.",
input="In one sentence, what is a Foundry project?",
)
print(response.output_text)
print(response.usage.input_tokens, "in /", response.usage.output_tokens, "out")
get_openai_client() returns a standard openai.OpenAI client whose base URL is your project endpoint plus /openai/v1, and whose “API key” is a function that fetches fresh Entra ID tokens (scope https://ai.azure.com/.default). You never handle a token yourself.
Option 2: the OpenAI SDK directly
import os
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from openai import OpenAI
token_provider = get_bearer_token_provider(
DefaultAzureCredential(), "https://ai.azure.com/.default"
)
client = OpenAI(
base_url=os.environ["FOUNDRY_PROJECT_ENDPOINT"].rstrip("/") + "/openai/v1",
api_key=token_provider, # a callable: refreshed automatically, no api-version needed
)
response = client.responses.create(
model=os.environ["FOUNDRY_MODEL_DEPLOYMENT"],
input="Give me three uses of Azure AI Search.",
)
print(response.output_text)
Older Azure OpenAI code used the AzureOpenAI client with an api_version="2024-xx-xx" argument. The current /openai/v1 route needs no API version and works with the plain OpenAI client — the same code runs against OpenAI and Foundry by changing only base_url and the credential.
Responses API vs Chat Completions
The newer OpenAI-style API (client.responses.create). Takes input (a string or a list of items) and instructions, returns a list of output items (messages, tool calls, reasoning), supports server-side state through previous_response_id or conversations, and built-in tools. Foundry agents are built on it.
The classic API (client.chat.completions.create) that takes a messages list and returns choices. Stateless, widely supported, and still the way to call some partner models.
| Responses | Chat Completions | |
|---|---|---|
| Input | input + instructions | messages list with roles |
| Output | response.output_text, response.output items | choices[0].message.content |
| State | Optional server-side (previous_response_id, conversation) | You resend full history every time |
| Tools | Function tools plus built-in tools (file search, code interpreter, MCP, ...) | Function tools |
| Use for | New code and anything agent-like | Legacy code and some partner models |
# the same question with Chat Completions, on the account-level OpenAI endpoint
account_client = OpenAI(
base_url=f"https://{resource_name}.openai.azure.com/openai/v1",
api_key=token_provider,
)
completion = account_client.chat.completions.create(
model=os.environ["FOUNDRY_MODEL_DEPLOYMENT"],
messages=[
{"role": "system", "content": "You are a concise assistant."},
{"role": "user", "content": "What is a deployment in Foundry?"},
],
)
print(completion.choices[0].message.content)
print(completion.choices[0].finish_reason) # "stop", "length", "content_filter", "tool_calls"
Multi-turn conversations
With Responses you can let the service keep the history. Pass the previous response's ID and only send the new turn:
first = client.responses.create(
model=deployment,
input="My name is Priya and I manage the support team.",
)
second = client.responses.create(
model=deployment,
previous_response_id=first.id, # the service replays the earlier turn
input="What is my name and role?",
)
print(second.output_text) # "You are Priya, and you manage the support team."
For longer-lived chats, create a conversation object (client.conversations.create()) and pass conversation=conv.id on every call. The agent lessons use this pattern. Remember: stored history still counts as input tokens on every turn.
Streaming
Streaming sends tokens as they are generated, so users see text in under a second instead of waiting for the full reply.
stream = client.responses.create(
model=deployment,
input="Explain Global Standard vs Data Zone deployments in 4 bullet points.",
stream=True,
)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif event.type == "response.completed":
usage = event.response.usage
print(f"\n[{usage.input_tokens} in / {usage.output_tokens} out]")
Structured output
When code consumes the answer, ask for JSON that matches a schema. The model is constrained to produce valid output.
from pydantic import BaseModel
class Ticket(BaseModel):
category: str # "billing" | "shipping" | "technical" | "other"
urgency: int # 1 (low) .. 3 (high)
summary: str
parsed = client.responses.parse(
model=deployment,
instructions="Classify the customer message into a support ticket.",
input="My card was charged twice for order A1002 and I need it fixed today!",
text_format=Ticket,
)
ticket: Ticket = parsed.output_parsed
print(ticket.model_dump())
Embeddings
Embeddings turn text into vectors for search and clustering. Deploy an embedding model (for example text-embedding-3-large) and call it through the account-level OpenAI endpoint:
embed_client = OpenAI(
base_url=f"https://{resource_name}.openai.azure.com/openai/v1",
api_key=token_provider,
)
result = embed_client.embeddings.create(
model="text-embedding-3-large", # your embedding deployment name
input=["refund policy for damaged items", "how do I return a product?"],
)
print(len(result.data), "vectors of", len(result.data[0].embedding), "dimensions")
Errors you will see
| Status / error | Meaning | Fix |
|---|---|---|
401 Unauthorized | No valid token or key | Run az login; check the token scope and endpoint |
403 Forbidden | Authenticated but missing a role, or blocked by network rules | Assign Foundry User; check firewall or private endpoint |
404 DeploymentNotFound | The model value is not a deployment on this resource | Use the deployment name, not the catalog name; check the endpoint |
400 content_filter | The prompt was blocked by a guardrail | Handle gracefully; see the guardrails lesson |
429 Too Many Requests | TPM or RPM exceeded | Retry with backoff using retry-after; raise quota; reduce max_output_tokens |
500 / 503 | Transient service issue | Retry with backoff; consider a fallback deployment in another region |
Retries that respect rate limits
The OpenAI SDK retries 429 and 5xx errors twice by default (configure with max_retries). For high-traffic services, understand the logic yourself: honour the service's retry-after header when present, otherwise back off exponentially with jitter (randomness) so many clients do not retry in lockstep.
import random
class RateLimited(Exception):
def __init__(self, retry_after: float | None):
self.retry_after = retry_after
def call_with_retry(fn, max_attempts=5, base=1.0, cap=30.0, sleep=print):
for attempt in range(1, max_attempts + 1):
try:
return fn()
except RateLimited as err:
if attempt == max_attempts:
raise
if err.retry_after is not None:
wait = err.retry_after # the service told us exactly
else:
wait = min(cap, base * 2 ** (attempt - 1)) * random.uniform(0.5, 1.0)
sleep(f"attempt {attempt}: 429, waiting {wait:.2f}s")
random.seed(7)
responses = iter([RateLimited(None), RateLimited(None), RateLimited(4.0), "ok: answer text"])
def fake_model_call():
item = next(responses)
if isinstance(item, Exception):
raise item
return item
print(call_with_retry(fake_model_call))
# in real code, configure the SDK instead of writing your own loop
client = project.get_openai_client(max_retries=5, timeout=60.0)
Retries hide problems. Log every 429 with the deployment name; if they are frequent, raise capacity or spread traffic across deployments rather than retrying harder.
- Run the Option 1 example, then change
modelto the catalog name of a model you have not deployed and read the 404. - Stream a 300-word answer and measure time to first token with
time.perf_counter(). - Add a
priorityfield to theTicketschema and check the model fills it.
- Use
AIProjectClient(...).get_openai_client()for one keyless client; or the plain OpenAI SDK withbase_url=<project>/openai/v1. - The
modelfield is your deployment name. - Prefer the Responses API for new code; Chat Completions remains for legacy and some partner models.
- Stream for user-facing text; use
responses.parsewith a schema when code consumes the output. - Handle 401/403/404/429 distinctly, honour
retry-after, and back off with jitter.