Skip to content

The application boundary

  • Lesson 4
  • 50 min
  • Module 1: Start with one model request

What you will be able to do

Put every line of code that talks to a model provider in one place. Keep the API key out of the code and out of the browser, and make every provider failure a clear message. Then you can change the provider without touching the rest of the assistant.

Before you startWhat to know and to have readyAbout 50 minutes

What you need to know

What you need on your computer

  • The project ticket-assistant with assistant/raw.py from the last lesson, .venv active, and python -m pytest giving 2 passed.
  • The labs folder ~/projects/nextia-learning-labs from A first run without an account. You copy three test files from it.
  • Optional, for the module practice: Ollama and gemma3:4b, as in the last lesson. Without them, you still see the failure path, which is the point.

You need no account and no key.

When you travel to another country, your phone charger does not fit the wall socket. You do not rebuild the charger. You put a small travel adapter between the plug and the socket. The phone and the charger stay the same in every country; only the adapter knows the local socket.

The same idea protects your house key. You keep it in your pocket, not taped to the front door. Anyone who reaches the door can use a key that is taped to it.

The problem: provider code everywhere, and a key in the wrong place

Section titled “The problem: provider code everywhere, and a key in the wrong place”

Imagine the assistant without a boundary. The help-desk code imports the provider’s SDK in five places, and each place reads the key in its own way. Then TermLarkfieldAn online shop for the home. It is not a real company. All Nextia courses use its help desk and data as the example.Learn more AI in everyday productsAI Career Foundations: Roles, Skills and Roadmaps moves from one provider to another, or to a local model for testing. TermAmiraLarkfield’s developer. She writes the code of the help-desk tools in the courses. must find and change every place, and test each one again.

TermTomásLarkfield’s AI engineer. He often suggests a quick shortcut, and the lesson shows why it fails. has a faster idea. “Why do we need a server at all? The help-desk app runs in the agent’s browser. Put the API key in the page’s JavaScript and call the provider from there.” It works in a demo. The section “Tomás’s shortcut” below shows what anyone can then do with the key.

There is a third problem: the provider fails, sometimes. It is slow, it refuses a key, or it cannot be reached. Without a plan, each failure shows up as a long Python traceback in the middle of the agent’s screen.

An application boundary is the line between your application and something that you do not control, here the provider. Behind the line, the code is split into three parts, and each part has one job:

  • Business logic reads the ticket, builds the request and uses the answer. It knows nothing about providers.
  • The adapter is the only code that talks to a provider. Like the travel adapter, it turns one standard call, complete(request), into the provider’s own call. It also turns the provider’s errors into the assistant’s own errors.
  • Configuration says which provider, which model, which address, which key and how long to wait. It comes from TermEnvironment variableA named value that the system gives to a program when it starts, for example an API key. The value stays out of the code and out of Git.Learn more Configuration and secretsPython for Practical AI Engineering, not from the code.
Inside a dashed zone, Larkfield's server, trusted: business logic (__main__.py, context.py) sends a request, a dict, to the adapter (providers.py: MockProvider, OpenAICompatibleProvider, the only code that calls a provider, turns its errors into ProviderError). Configuration (config.py and .env, never committed: provider, model, base URL, API key, timeout) gives the key only to the adapter. The adapter reads recordings for the mock. Outside the zone: the help-desk app in the agent's browser, with no key, sends the ticket to the business logic; the adapter sends HTTPS with the key to the provider: Azure, OpenAI or Ollama.
The application boundary of ticket-assistant at the end of this module. The key goes from the configuration to the adapter, and nowhere else.
Text description of the diagram

A dashed zone marks Larkfield’s server, which is trusted. Inside it are four parts. The business logic, in main.py and context.py, reads the ticket, builds the request and uses the answer. It hands the request, a Python dictionary, to the adapter in providers.py. The adapter holds MockProvider and OpenAICompatibleProvider: it is the only code that calls a provider, and it turns the provider’s errors into ProviderError. The configuration is config.py with the .env file, which is never committed. It holds the provider, the model, the base URL, the API key and the timeout, and gives the key only to the adapter. For the mock, the adapter reads the recordings. Outside the zone, on the left, the help-desk app runs in the agent’s browser and has no key. It sends the ticket to the server. On the right, the adapter sends HTTPS requests with the key to the provider: Azure, OpenAI or Ollama on your computer.

Why this split helps: a change has one place to go. A new provider means a new adapter class. A new model means one line in .env. Code that uses the answer never changes for either.

A tiny worked example: settings that hide the key

Section titled “A tiny worked example: settings that hide the key”

Here are the settings and the adapter choice at work, in a Python session in the finished project. The key is a made-up test value.

from assistant.config import Settings, make_provider, redact
settings = Settings(provider="openai_compatible", model="gemma3:4b", base_url="http://localhost:11434/v1", api_key="test-key-1234567890abcdef")
settings
make_provider(settings)
make_provider(Settings())
print(redact("Request failed. Authorization: Bearer abcdefgh12345678"))
print(redact("helen.brooks@example.com paid with 4111 1111 1111 1111 for LK-714258"))

The output:

Settings(provider='openai_compatible', model='gemma3:4b', base_url='http://localhost:11434/v1', timeout_s=30.0, reasoning_effort='')
<assistant.providers.OpenAICompatibleProvider object at 0x100e7d6d0>
<assistant.providers.MockProvider object at 0x107d22e40>
Request failed. Authorization: [REDACTED]
[EMAIL REDACTED] paid with [CARD NUMBER REDACTED] for LK-714258

Four things to see. Printing the settings shows everything except the key. make_provider picks the adapter from the settings, and the default settings give the mock. redact hides a key, an email address and a card number before text goes to a log. The order ID stays, because a log needs it. Your object addresses (0x…) differ.

The adapter gets a second provider, OpenAICompatibleProvider. It calls any server that speaks the TermChat CompletionsThe request and response format of OpenAI’s chat API: a model name, a list of messages with roles, and settings. Azure, Ollama and many other providers accept the same format (“OpenAI-compatible”), so one SDK works with all of them.Learn more Request anatomyBuilding Reliable Applications with LLM APIs Chat Completions API, through the openai SDK. Open assistant/providers.py, and make four changes.

  1. In the docstring at the top, replace the sentence about one provider:

    assistant/providers.py (docstring)
    """The adapter: the only code that talks to a model provider.
    The rest of the assistant builds a request (a dict in the Chat Completions format) and gets back a
    `Completion`. Two providers do the work:
    - MockProvider replays responses that were recorded for the course. No account, no network.
    - OpenAICompatibleProvider calls any server that speaks the OpenAI-compatible Chat Completions API:
    a local Ollama server, OpenAI, or another provider. Its settings come from environment variables.
    """
  2. Add two imports, so that the import lines read:

    assistant/providers.py (imports)
    import contextlib
    import hashlib
    import json
    import time
    from dataclasses import dataclass, field
    from pathlib import Path
  3. After the class ProviderError, add two more errors:

    assistant/providers.py (after ProviderError)
    class ProviderTimeout(ProviderError):
    """No complete answer within the timeout."""
    class RateLimited(ProviderError):
    """HTTP 429: too many requests. `retry_after` is the wait in seconds that the provider asked for."""
    def __init__(self, message: str, retry_after: float | None = None):
    super().__init__(message, status=429)
    self.retry_after = retry_after
  4. At the end of the file, add the error translation and the new provider:

    assistant/providers.py (end of the file)
    @contextlib.contextmanager
    def translate_errors():
    """Turn the SDK's exceptions into the assistant's own, so that no other file imports `openai`."""
    import openai
    try:
    yield
    except openai.APITimeoutError as e:
    raise ProviderTimeout("The provider did not answer within the timeout.") from e
    except openai.RateLimitError as e:
    wait = e.response.headers.get("retry-after")
    raise RateLimited("HTTP 429: rate limited.", float(wait) if wait else None) from e
    except openai.APIStatusError as e:
    raise ProviderError(f"HTTP {e.status_code}: {e.message}", status=e.status_code) from e
    except openai.APIConnectionError as e:
    raise ProviderError("Cannot reach the provider (connection error).") from e
    class OpenAICompatibleProvider:
    """Calls a server that speaks the OpenAI-compatible Chat Completions API."""
    def __init__(self, base_url: str, api_key: str, timeout_s: float = 30.0):
    from openai import OpenAI # imported here, so that the mock works without a network library set up
    # max_retries=0: the assistant decides about retries itself (retry.py).
    self.client = OpenAI(base_url=base_url, api_key=api_key or "not-needed", timeout=timeout_s, max_retries=0)
    def complete(self, request: dict) -> Completion:
    start = time.monotonic()
    with translate_errors():
    response = self.client.chat.completions.create(**request)
    return Completion.from_response(response.model_dump(), round(time.monotonic() - start, 3))

Three decisions are in these lines. First, a timeout: the most time that the code waits for an answer, here 30 seconds unless the settings say otherwise. Without one, a stuck connection can hold the agent’s screen for minutes. Second, max_retries=0. The SDK retries some errors by itself, but a retry repeats a request, and repeating it is a decision about time and money. Module 6 makes that decision in retry.py, so the SDK must not. Third, translate_errors turns every SDK error into a ProviderError. The rest of the assistant handles one kind of error and never imports openai. TermOllamaA free program that downloads open models and runs them on your own computer. Your code calls it at a local address, as it calls a hosted API.Learn more Hosted and self-hosted choicesLLMs: How They Work and How to Choose One needs no key, so "not-needed" fills the SDK’s required argument.

Make the file assistant/config.py. It reads the settings from environment variables, and from a file .env in the project folder if one exists.

assistant/config.py
"""Configuration from environment variables, the choice of provider, and redaction of secrets.
The key is read here and given only to the provider. It is never printed, logged or saved.
"""
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from .providers import MockProvider, OpenAICompatibleProvider
ENV_FILE = Path(__file__).resolve().parent.parent / ".env"
def load_env_file(path: Path | None = None) -> None:
"""Read KEY=value lines from .env into the environment (variables already set win)."""
path = path or ENV_FILE # looked up at call time, so that a test can point ENV_FILE elsewhere
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
name, value = line.split("=", 1)
os.environ.setdefault(name.strip(), value.strip().strip('"').strip("'"))
@dataclass(frozen=True)
class Settings:
provider: str = "mock" # "mock" or "openai_compatible"
model: str = "chat-small" # the recordings were made with chat-small and chat-strong
base_url: str = "" # for example http://localhost:11434/v1 for Ollama
api_key: str = field(default="", repr=False) # repr=False: printing the settings never shows the key
timeout_s: float = 30.0
reasoning_effort: str = "" # sent only when set (some models need it, others reject it)
@classmethod
def from_env(cls) -> "Settings":
load_env_file()
env = os.environ
return cls(
provider=env.get("ASSISTANT_PROVIDER", "mock"),
model=env.get("ASSISTANT_MODEL", "chat-small"),
base_url=env.get("ASSISTANT_BASE_URL", ""),
api_key=env.get("ASSISTANT_API_KEY", ""),
timeout_s=float(env.get("ASSISTANT_TIMEOUT_S", "30")),
reasoning_effort=env.get("ASSISTANT_REASONING_EFFORT", ""),
)
def make_provider(settings: Settings):
if settings.provider == "mock":
return MockProvider()
if settings.provider == "openai_compatible":
if not settings.base_url:
raise ValueError("Set ASSISTANT_BASE_URL, for example http://localhost:11434/v1 for Ollama.")
return OpenAICompatibleProvider(settings.base_url, settings.api_key, settings.timeout_s)
raise ValueError(f"Unknown ASSISTANT_PROVIDER {settings.provider!r}: use mock or openai_compatible.")
SECRET_PATTERNS = [
(re.compile(r"sk-[A-Za-z0-9_\-]{8,}"), "sk-[REDACTED]"),
(re.compile(r"(?i)(api[-_]?key|authorization)(\"?\s*[:=]\s*\"?)(bearer\s+)?[^\s\",}]+"), r"\1\2[REDACTED]"),
(re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{8,}"), "Bearer [REDACTED]"),
(re.compile(r"\b\d(?:[ -]?\d){12,18}\b"), "[CARD NUMBER REDACTED]"), # 13-19 digits, with spaces or dashes
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "[EMAIL REDACTED]"),
]
def redact(text: str) -> str:
"""Hide keys, card numbers and email addresses before text goes to a log."""
for pattern, replacement in SECRET_PATTERNS:
text = pattern.sub(replacement, text)
return text
Part Why it is there
load_env_file() Reads .env. A variable that the shell already set wins, so a one-off command can change one setting.
Settings All settings in one object. frozen=True: nothing changes them by accident. repr=False on api_key: printing or logging the settings never shows the key.
make_provider() The one place that chooses the adapter. The default is the mock, so the project works with no .env at all.
redact() Hides keys, card numbers and email addresses before text goes to a log. A pattern list is a safety net: it catches the usual shapes, not every secret.

Make the file .env.example in the project folder. It is the template for your own .env, and it holds no secret, so it is safe to commit.

.env.example
# Copy this file to .env and fill it in. Never commit .env: it can hold a key.
# The default needs nothing: the mock provider replays the course's recorded responses.
ASSISTANT_PROVIDER=mock
ASSISTANT_MODEL=chat-small
# Option 1, free and local: Ollama (https://ollama.com), then `ollama pull gemma3:4b`
# ASSISTANT_PROVIDER=openai_compatible
# ASSISTANT_BASE_URL=http://localhost:11434/v1
# ASSISTANT_MODEL=gemma3:4b
# Option 2, your own key with a hosted provider that offers the OpenAI-compatible API (paid per token)
# ASSISTANT_PROVIDER=openai_compatible
# ASSISTANT_BASE_URL=https://api.openai.com/v1
# ASSISTANT_MODEL=<a model name from your provider>
# ASSISTANT_API_KEY=<your key>
# Optional
# ASSISTANT_TIMEOUT_S=30
# ASSISTANT_REASONING_EFFORT= (some reasoning models need "none" to use tools; most models need nothing)

The project’s .gitignore already lists .env, and keeps .env.example with the line !.env.example. So Git never takes your real .env, even with git add ..

Step 4: the command line and raw.py use the settings

Section titled “Step 4: the command line and raw.py use the settings”
  1. Replace assistant/__main__.py with this version. It asks the configuration for the provider, and it turns a provider failure into one clear line:

    assistant/__main__.py
    """Command line.
    python -m assistant analyse T-80008 analyse one ticket (the provider comes from .env; default: mock)
    """
    import argparse
    import json
    import sys
    from .config import Settings, make_provider
    from .context import build_request
    from .data import load_ticket
    from .providers import ProviderError
    def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="python -m assistant")
    commands = parser.add_subparsers(dest="command", required=True)
    commands.add_parser("analyse", help="analyse one ticket").add_argument("ticket_id")
    args = parser.parse_args(argv)
    try:
    ticket = load_ticket(args.ticket_id)
    except KeyError as error:
    print(error.args[0], file=sys.stderr)
    return 2
    settings = Settings.from_env()
    request = build_request(ticket, settings.model, "v1")
    try:
    completion = make_provider(settings).complete(request)
    except ProviderError as error: # the provider failed: say so clearly, without a traceback
    print(f"No answer: {error}", file=sys.stderr)
    return 1
    try:
    print(json.dumps(json.loads(completion.text), indent=2, ensure_ascii=False))
    except (TypeError, json.JSONDecodeError):
    print(completion.text)
    print("(This answer is not valid JSON.)")
    print(f"{completion.model} | finish_reason: {completion.finish_reason} | {completion.input_tokens} tokens in, "
    f"{completion.output_tokens} out | {completion.latency_s} s")
    return 0
    if __name__ == "__main__":
    sys.exit(main())

    The summary line no longer says “when it was recorded”: with a live provider, the time is real.

  2. In assistant/raw.py, use the settings too. Delete import os, and add from .config import Settings just before from .context import build_request. Then change main so that its first lines and its --send part read:

    assistant/raw.py (main, changed lines)
    def main(argv: list[str]) -> int:
    settings = Settings.from_env()
    ticket = load_ticket(argv[0] if argv else "T-80008")
    body = build_request(ticket, settings.model, "v1")
    print(json.dumps(body, indent=2, ensure_ascii=False))
    if "--send" not in argv:
    return 0
    if not settings.base_url:
    print("Set ASSISTANT_BASE_URL first (see .env.example).", file=sys.stderr)
    return 2
    headers = {"Content-Type": "application/json"}
    if settings.api_key:
    headers["Authorization"] = f"Bearer {settings.api_key}"
    response = httpx.post(settings.base_url.rstrip("/") + "/chat/completions", json=body, headers=headers,
    timeout=settings.timeout_s)

    The rest of the file stays as it is. Now raw.py reads .env too, and it uses the same timeout as the assistant.

  3. Copy the three new test files from the snapshot:

    Terminal window
    cp ../nextia-learning-labs/C08/snapshots/end-of-m01/tests/test_*.py tests/

    test_providers.py checks the request key and the mock. test_config.py checks that the key never shows and that redact works. test_live.py sends one live request, but only when you ask for it.

  4. Run the tests:

    Terminal window
    python -m pytest

    The last line:

    ======================= 15 passed, 1 deselected in 0.02s =======================

    The deselected test is the live one. pytest.ini leaves it out unless you run python -m pytest -m live.

  5. Run the first command again: python -m assistant analyse T-80008. The JSON is the same as before, and the last line is gpt-6-luna-2026-09-22 | finish_reason: stop | 1032 tokens in, 241 out | 2.678 s.

Your project now matches the end-of-m01 snapshot. On macOS and Linux, you can check it with diff -r -x .venv -x __pycache__ -x .pytest_cache ../nextia-learning-labs/C08/snapshots/end-of-m01 .: no output means no difference.

Tomás’s shortcut: the key in the browser

Section titled “Tomás’s shortcut: the key in the browser”

Tomás’s idea removes the server: the agent’s browser calls the provider directly, with the key in the page’s JavaScript. The diagram compares it with Amira’s design.

Top: Tomás's shortcut, the key in the page. The agent's browser, whose JavaScript holds the key, sends HTTPS with Authorization: Bearer and the key directly to the provider. A dashed note: developer tools, Network tab: anyone who opens the page sees the header, copies the key and spends Larkfield's money. Bottom: Amira's design, the key stays on the server. The agent's browser, with no key, sends the ticket and the agent's session to Larkfield's server, which holds the key in an environment variable and sends HTTPS with the key to the provider.
Where the key lives decides who can use it. Code that runs in a browser is visible to every person who can open the page.
Text description of the diagram

Two designs. In the top one, Tomás’s shortcut, the page’s JavaScript in the agent’s browser holds the key. The browser sends the request with “Authorization: Bearer” and the key straight to the provider. A note says that the Network tab of the browser’s developer tools shows this header to anyone who opens the page. They can copy the key and spend Larkfield’s money. In the bottom one, Amira’s design, the browser has no key. It sends the ticket and the agent’s session to Larkfield’s server. The server holds the key in an environment variable and sends the request, with the key, to the provider.

Common mistake

The key in the browser

Symptom: the demo works, and nothing looks wrong. The danger is invisible until someone looks. A visitor opens the help-desk page, presses F12 for the developer tools, and selects the Network tab. In the request to the provider, the headers show Authorization: Bearer followed by the full key. The same key is in the page’s JavaScript file, which the Sources tab shows. This is a description of what the browser shows; the course did not publish a page with a real key.

Cause: everything that a browser runs, the browser can show. Code in a page is not secret, and minified or hidden code is still readable. A key in a page is a key taped to the front door. Whoever copies it can send any request on Larkfield’s account, at Larkfield’s cost, until someone notices and revokes the key.

Fix: keep the key on the server, in an environment variable, and give it only to the adapter. The browser talks to Larkfield’s own server, which checks who the agent is and then calls the provider. If a key was ever in a page, a log or a commit, treat it as leaked. Create a new key at the provider, and delete the old one. Deleting the file is not enough, because copies may exist.

What it means for a real AI service: the server in the middle is not overhead. It is the place for the key, and later for the checks of this course: the schema, the permissions and the budget. A browser cannot enforce any of them.

The adapter now has three kinds of failure. A timeout: no answer in time. A TermRate limitThe most that one customer may send to a provider in a period, for example 100,000 tokens and 100 requests per minute. Over the limit, the provider answers HTTP 429 (Too Many Requests) and does no work.Learn more Handle provider limitsBuilding Reliable Applications with LLM APIs: HTTP 429, too many requests (Module 6 handles it). And every other error: a wrong key, a request that the provider refuses, a server error, or no connection at all. Each one becomes a ProviderError, and the command line prints one line that starts with No answer: and exits with code 1.

A decision flow. The adapter calls the provider, with the timeout ASSISTANT_TIMEOUT_S. What came back? An answer: a Completion, printed, exit code 0. Nothing in time: ProviderTimeout. HTTP 429: RateLimited with Retry-After. HTTP 400, 401 or 5xx: ProviderError with the status. No connection: ProviderError, cannot reach the provider. All four errors lead to: the CLI prints No answer: … and exits with 1, with no traceback and no key.
Every way a call can end. Four kinds of failure become one clear message, and the key never appears in it.
Text description of the diagram

The adapter calls the provider and waits at most ASSISTANT_TIMEOUT_S seconds. If an answer comes back, it becomes a Completion, which the command prints, with exit code 0. If nothing comes back in time, the adapter raises ProviderTimeout. HTTP 429 becomes RateLimited, with the wait time from the Retry-After header. HTTP 400, 401 or a 5xx server error becomes a ProviderError with the status. A failed connection becomes a ProviderError that says the provider cannot be reached. All four end the same way: the command prints “No answer:” with the reason and exits with code 1, with no traceback and no key.

These are real failures of the finished project. The first needs nothing: the address points to Ollama, but Ollama is not running.

Terminal window
ASSISTANT_PROVIDER=openai_compatible ASSISTANT_BASE_URL=http://localhost:11434/v1 ASSISTANT_MODEL=gemma3:4b python -m assistant analyse T-80008
No answer: Cannot reach the provider (connection error).

A wrong key, sent to Termchat-smallThe name that Larkfield’s code gives to a small, low-cost hosted model: gpt-6-luna on Azure. The code uses the name, so the model can change without a code change.Learn more What an LLM API isBuilding Reliable Applications with LLM APIs on Azure (ASSISTANT_API_KEY=not-a-real-key-0000):

No answer: HTTP 401: Error code: 401 - {'error': {'code': '401', 'message': 'Access denied due to invalid subscription key or wrong API endpoint. Make sure to provide a valid key for an active subscription and use a correct regional API endpoint for your resource.'}}

And a timeout of 1 second, ASSISTANT_TIMEOUT_S=1. chat-small needs about 3 seconds, and a local model that is still loading needs longer. Both gave the same real result:

No answer: The provider did not answer within the timeout.

None of these messages contains the key. The 401 message repeats the provider’s own text, which says what is wrong without showing the key that was sent. Choose the timeout from real latencies. On the course’s tickets, chat-small took 2.72 s at the TermMedianThe middle value when you sort the values. Half are lower and half are higher. One very slow call does not change it much, so it is a good “usual” time.Learn more Measure practical performanceLLMs: How They Work and How to Choose One, and 4.72 s for the slowest 5%. 30 seconds leaves room for slow moments and still frees the agent’s screen.

Module practice: from the mock to a local model, and back

Section titled “Module practice: from the mock to a local model, and back”

This practice uses .env, as a real deployment does. It shows the boundary at work: you change the provider without changing one line of code.

  1. Copy the template to your own .env:

    Terminal window
    cp .env.example .env
  2. In .env, put # before the two mock lines. Remove # from the three lines of Option 1. Save the file.

  3. If Ollama is not running, run python -m assistant analyse T-80008 now. You see No answer: Cannot reach the provider (connection error). If you have no Ollama, go to step 6.

  4. Start Ollama, and run the command again. The answer comes from TermGemma 3 4BA small open model from Google, with 4 billion parameters. It can run on a laptop with Ollama, without an account or a cost per call.Learn more Hosted and self-hosted choicesLLMs: How They Work and How to Choose One. In the course’s run, it was wrapped in a TermMarkdownA plain-text way to show formatting, for example **bold** or # for a heading. Models often add it, so a program that expects plain JSON must remove it or reject the answer. fence, so the command printed the text and (This answer is not valid JSON.). The last line was gemma3:4b | finish_reason: stop | 1092 tokens in, 193 out | 9.011 s.

  5. Run the live test: python -m pytest -m live. The course’s run gave 1 passed, 15 deselected. The other tests still use the mock: conftest.py sets the mock for every test that is not marked live.

  6. Go back to the mock: delete .env, or put the mock lines back. Run python -m assistant analyse T-80008. The known-good line is back: … 1032 tokens in, 241 out | 2.678 s.

  7. Run python -m pytest. You see 15 passed, 1 deselected.

Show the full Gemma 3 4B answer from the course’s run

Recorded with Ollama 0.40.1, through the finished project:

```json
{
"language": "en",
"team": "delivery",
"needs_human": true,
"reason": "The customer is inquiring about the status of their order and the delivery date, which falls under the delivery team's purview, and requires investigation.",
"confidence": "high",
"order": {
"order_id": "LK-581106",
"status": null
},
"reply": "Hello, thank you for contacting us regarding order LK-581106. I'm so sorry for the delay in receiving your delivery date – let me investigate this immediately for you. I’ll check the tracking information to see the current status of your garden shed and provide you with an estimated delivery timeframe. We appreciate your patience and will keep you updated on the progress."
}
```
(This answer is not valid JSON.)
gemma3:4b | finish_reason: stop | 1092 tokens in, 193 out | 9.011 s

The team is right, but needs_human is true for a simple delivery question, and the answer is not valid JSON. The next modules deal with both: Module 2 with the prompt, Module 3 with the check.

Set a fake key for one command, and try to make the project show it. On macOS or Linux:

Terminal window
ASSISTANT_API_KEY=test-key-1234567890abcdef python -c "from assistant.config import Settings; print(Settings.from_env())"

Before you run it, predict the output. Then search the whole output for test-key.

Show the answer

The output shows the settings without the key: Settings(provider='mock', model='chat-small', base_url='', timeout_s=30.0, reasoning_effort=''). repr=False keeps api_key out of the printed text, so test-key is nowhere. The key is still in settings.api_key, because the adapter needs it. Hiding it from printing protects the logs, where most leaks happen; it does not make the value secret inside the program.

Answer in your own words, then compare with the example answer.

  1. Which code in ticket-assistant handles the API key? Name every place that the key passes through, and the places where it must never go.
  2. What happens when the provider fails? Take three cases: the provider is not running, the key is wrong, and the provider is too slow.
Show an example answer
  1. The key starts in an environment variable, ASSISTANT_API_KEY, set in the shell or in .env. Git ignores .env, and only .env.example, without a key, is committed. config.py reads it into Settings.api_key, which is hidden when the settings are printed. make_provider gives it to OpenAICompatibleProvider, and the SDK sends it in the Authorization header over HTTPS. No other code sees it. It must never be in the code, in a commit, in a log, in an error message or in a browser.
  2. The adapter translates every failure into a ProviderError, and the command line prints one line and exits with code 1. Not running: No answer: Cannot reach the provider (connection error). Wrong key: No answer: HTTP 401: … with the provider’s message and no key. Too slow: the timeout stops the wait, and the message is No answer: The provider did not answer within the timeout. The adapter does not retry, because the SDK’s retries are off. Module 6 adds retries with limits.

If your project does not pass 15 passed, 1 deselected, copy the snapshot end-of-m01 with the steps of Reset the project. Use end-of-m01 instead of start in the copy command. Module 2 starts from this snapshot.

Check your understanding

Larkfield moves from chat-small on Azure to a model at another provider with an OpenAI-compatible API. Which files change, and which do not?

Show the answer

No Python file changes. The new provider speaks the same API, so OpenAICompatibleProvider works with it. Only the configuration changes: ASSISTANT_BASE_URL, ASSISTANT_MODEL and ASSISTANT_API_KEY in .env or in the server’s environment. If the new provider had a different API, one new adapter class in providers.py would be needed, and make_provider would choose it. The business logic never changes.

Check your understanding

Tomás says: 'The page is only for our agents, behind a login. So the key in the JavaScript is safe.' What do you answer?

Show the answer

Every agent who logs in can open the developer tools and copy the key. So can anyone who gets an agent’s laptop or session. A login controls who sees the page, not what the page reveals. With the key, a person can call the provider directly, outside the help-desk app, with no checks and at Larkfield’s cost. The key belongs on the server, behind the login, never in the page.

Check your understanding

Predict: you set ASSISTANT_TIMEOUT_S=60 and the provider is down. What does the agent see, and after how long?

Show the answer

It depends on how the provider is down. If the connection is refused at once, the agent sees “No answer: Cannot reach the provider (connection error).” in about a second. If the provider accepts the connection but never answers, the agent waits the full 60 seconds. Then the agent sees “No answer: The provider did not answer within the timeout.” So the timeout is the longest wait that you accept: choose it from real latencies, not as large as possible.

Recap

  • The application boundary splits the code into business logic, an adapter (providers.py, the only code that calls a provider) and configuration (config.py and .env).
  • Settings come from environment variables. .env is never committed; .env.example is the template without secrets.
  • The API key lives only on the server and reaches only the adapter. Settings never prints it, and redact() hides keys, card numbers and email addresses before text goes to a log. A key in a browser is a leaked key.
  • Every provider failure becomes a ProviderError, and the command prints one No answer: line. A timeout limits the wait; the SDK’s own retries are off, so Module 6 decides about retries.
  • Changing from the mock to a local model and back needs no code change: only .env.

Models and APIs change fast; the numbers in this module are a snapshot. The boundary is what lasts. Next, Module 2 treats the prompt as a specification that you can test.

Next: Specify the task