Skip to main content
ANVISoftware Solutions
Lesson 10 of 22Intermediate18 min

Your First Model Integration

By the end of this lesson

Call a model API from code and handle errors, latency and cost.

Calling a model from code is three lines. Calling it in a way that survives a bad afternoon at the provider is the rest of this lesson. The difference is not model expertise. It is the ordinary discipline you would apply to any third-party HTTP dependency that is slow, occasionally unavailable, and billed per use.

Treat it that way and the surprises are small. The parts people skip are always the same four: where the key lives, what happens when the call hangs, what happens when you are rate limited, and whether anyone can tell afterwards what it cost.

Structure of an AI-powered applicationA user interacts with a web interface, which calls an API. The API handles authentication and rate limiting, then passes the request to an orchestration layer. Orchestration assembles the prompt, decides whether to retrieve documents or call a tool, and sends the result to the language model. The model's response returns through the same path. Retrieval reads from a vector store; tools call internal APIs.UserWeb interfaceAPIauth, rate limitsOrchestrationprompt, routing, validationRetrieverVector storeToolsinternal APIsLanguage modelModel output and tool arguments aretreated as untrusted input throughout.
Your application sits between the user and the model. The prompt is assembled, the call is made with a timeout, and the response is validated before anything reaches the user.
Set up the environment, and keep the key out of your code
Shell
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\\Scripts\\activate

pip install "openai>=1.40" "tenacity>=8.2" "pydantic>=2.7"

# Supply the key through the environment, never in a source file
export OPENAI_API_KEY="paste-your-own-key-here"

# Windows PowerShell equivalent
# $env:OPENAI_API_KEY = "paste-your-own-key-here"

# Confirm the variable is visible to the process that needs it
python -c "import os; print('key present:', bool(os.environ.get('OPENAI_API_KEY')))"
One call, with the four things people leave out
Python
import logging
import os
from openai import OpenAI, APITimeoutError, RateLimitError
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential

log = logging.getLogger(__name__)

client = OpenAI(
    api_key=os.environ["OPENAI_API_KEY"],   # fails at start-up if absent
    timeout=20.0,
    max_retries=0,                          # one visible retry policy, below
)

@retry(
    retry=retry_if_exception_type((RateLimitError, APITimeoutError)),
    wait=wait_exponential(multiplier=1, min=1, max=20),
    stop=stop_after_attempt(4),
    reraise=True,
)
def answer_policy_question(question: str, extract: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4.1-mini",
        temperature=0,
        max_completion_tokens=250,
        messages=[
            {
                "role": "system",
                "content": (
                    "Answer only from the POLICY block. If it does not cover the "
                    "question, reply exactly: Not covered by the supplied policy."
                ),
            },
            {"role": "user", "content": f"POLICY\n{extract}\n\nQUESTION\n{question}"},
        ],
    )

    choice = response.choices[0]
    log.info(
        "policy question answered",
        extra={
            "model": response.model,
            "prompt_tokens": response.usage.prompt_tokens,
            "completion_tokens": response.usage.completion_tokens,
            "finish_reason": choice.finish_reason,
        },
    )
    if choice.finish_reason == "length":
        log.warning("reply was truncated at the token cap")

    return choice.message.content
  • os.environ with square brackets raises immediately if the variable is missing, so a misconfigured deployment fails at start-up with an obvious message rather than later, in front of a user, as a confusing 401.
  • timeout=20.0 is the line most often absent. Without it a request can hang for as long as the connection stays open, holding a worker thread and a person who thinks the page is broken.
  • max_retries=0 switches off the SDK's built-in retries. Two retry mechanisms stacked on top of each other multiply, and the one you did not know about is the one that surprises you in the logs.
  • The retry applies to rate limits and timeouts only. Both are transient. A malformed request returns the same error four times in a row and the backoff just delays the failure.
  • wait_exponential spaces the attempts roughly 1, 2, 4 and 8 seconds apart. Retrying instantly makes a rate limit worse, because your retries are part of the rate you are exceeding.
  • response.usage is where the token counts live. Log them with the model name on every call. Without that line you cannot answer why the bill moved, or which feature is responsible for most of it.
  • finish_reason tells you whether the reply ended naturally or hit max_completion_tokens. A truncated answer displayed as a finished one is a bug your users will report as the assistant being cut off mid-sentence.

What will go wrong, and what to do about each:

Timeout or connection failure
Retry with backoff a small number of times, then return a clear message. Set the timeout deliberately: long enough for a real answer, short enough that a user is not left staring at a spinner.
Rate limit
Expected behaviour under load, not an outage. Back off exponentially, and if it is constant, queue the work or ask the provider about limits. Tight retry loops on a rate limit make the problem worse.
Provider error in the 500 range
Retry once or twice, then fail cleanly. Decide in advance what your feature does when the model is unavailable — often falling back to keyword search and the source documents is better than an error page.
Invalid request
Your bug: a bad parameter, an unsupported model name, or a prompt over the context limit. Do not retry it. Log the request shape, without logging the key or any personal data from the prompt.
Truncated or refused output
Check finish_reason. A reply cut off at the cap needs either a higher cap or a shorter answer. A refusal needs a defined user-facing path, not a blank panel.
Streaming, for perceived speed rather than actual speed
Python
def stream_policy_answer(question: str, extract: str):
    stream = client.chat.completions.create(
        model="gpt-4.1-mini",
        temperature=0,
        max_completion_tokens=250,
        stream=True,
        messages=build_messages(question, extract),
    )
    for event in stream:
        piece = event.choices[0].delta.content
        if piece:
            yield piece
  • The reply arrives in fragments as the tokens are produced, so the first words appear in a fraction of a second instead of after the whole answer is ready.
  • Total time is unchanged. What changes is the waiting, which stops being a blank screen. For a two-sentence answer this matters little; for a long summary it is the difference between usable and abandoned.
  • The trade-off is real: you are showing output before you have seen all of it. Anything you intend to validate, parse, or check against a guardrail cannot be streamed straight to the user, because the check needs the whole response.
  • Usage figures may arrive differently when streaming, sometimes only in a final event and sometimes needing an extra option on the request. Check the current reference — and keep logging them, because a streamed call costs exactly the same as a buffered one.

Summary

  • Read the key from the environment and fail at start-up if it is missing; never commit it and never ship it to a browser
  • Set a timeout on every call, and keep one retry policy you can see rather than two stacked layers
  • Retry transient failures only — rate limits and timeouts — with exponential backoff and a hard attempt cap
  • Log model, token usage and finish_reason on every call, and handle a reply truncated at the cap
  • Streaming improves perceived latency, not total time, and rules out checking the output before it is shown

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Break it on purpose

Get the call working, then break it four ways and watch what your code does. Remove the environment variable. Set the timeout to 0.001 seconds. Set max_completion_tokens to 20 and ask for a long answer. Pass a model name that does not exist.

For each, note where the failure surfaced and whether the message would tell a colleague at 2am what was wrong.

Show solution

The missing variable should fail at import or start-up, loudly. If it failed on the first user request instead, your key is being read with a silent default somewhere.

The tiny timeout raises a timeout error and, with the retry in place, does so four times with growing pauses. This is the case to sit with: retries on a systematic failure turn one fast error into a slow one, which is why the retry is scoped to transient classes and capped.

The 20-token cap gives you a reply with finish_reason of length, cut off mid-sentence. If your code returns it as a normal answer, you have found the bug this lesson's logging line exists to catch.

The bad model name returns an invalid-request error. Confirm your retry does not fire on it. If it does, the retry condition is too broad.

Doing this deliberately, once, is worth more than reading about it. Every one of these appears in production eventually, and each one is cheap to handle before it does.

Saved in this browser only.