The application boundary
- Lesson 4
- 50 min
- Module 1: Start with one model request
Lesson 4 of 19 · Building Reliable Applications with LLM APIsThe application boundary
1. Start with one model request
2. Prompting as an engineering task
3. Structured outputs and validation
4. Tool calling safely
5. Conversations and streaming
6. Reliability and cost controls
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 ready
What you need to know
- The parts of a request, and what the key does, from Request anatomy.
- Dataclasses and exceptions in Python, from Structured records and Exceptions and logging. Environment variables and secrets, from Configuration and secrets. All three are in Python for Practical AI Engineering.
What you need on your computer
- The project
ticket-assistantwithassistant/raw.pyfrom the last lesson,.venvactive, andpython -m pytestgiving2 passed. - The labs folder
~/projects/nextia-learning-labsfrom 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.
A travel adapter
Section titled “A travel adapter”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.
The idea: one boundary, three parts
Section titled “The idea: one boundary, three parts”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.
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, redactsettings = Settings(provider="openai_compatible", model="gemma3:4b", base_url="http://localhost:11434/v1", api_key="test-key-1234567890abcdef")settingsmake_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-714258Four 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.
Step 1: the live provider in the adapter
Section titled “Step 1: the live provider in the adapter”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.
-
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.""" -
Add two imports, so that the import lines read:
assistant/providers.py (imports) import contextlibimport hashlibimport jsonimport timefrom dataclasses import dataclass, fieldfrom pathlib import Path -
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 -
At the end of the file, add the error translation and the new provider:
assistant/providers.py (end of the file) @contextlib.contextmanagerdef translate_errors():"""Turn the SDK's exceptions into the assistant's own, so that no other file imports `openai`."""import openaitry:yieldexcept openai.APITimeoutError as e:raise ProviderTimeout("The provider did not answer within the timeout.") from eexcept openai.RateLimitError as e:wait = e.response.headers.get("retry-after")raise RateLimited("HTTP 429: rate limited.", float(wait) if wait else None) from eexcept openai.APIStatusError as e:raise ProviderError(f"HTTP {e.status_code}: {e.message}", status=e.status_code) from eexcept openai.APIConnectionError as e:raise ProviderError("Cannot reach the provider (connection error).") from eclass 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.
Step 2: configuration in one place
Section titled “Step 2: configuration in one place”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.
"""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 osimport refrom dataclasses import dataclass, fieldfrom 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. |
Step 3: the file of example settings
Section titled “Step 3: the file of example settings”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.
# 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=mockASSISTANT_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”-
Replace
assistant/__main__.pywith 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 argparseimport jsonimport sysfrom .config import Settings, make_providerfrom .context import build_requestfrom .data import load_ticketfrom .providers import ProviderErrordef 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 2settings = 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 tracebackprint(f"No answer: {error}", file=sys.stderr)return 1try: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 0if __name__ == "__main__":sys.exit(main())The summary line no longer says “when it was recorded”: with a live provider, the time is real.
-
In
assistant/raw.py, use the settings too. Deleteimport os, and addfrom .config import Settingsjust beforefrom .context import build_request. Then changemainso that its first lines and its--sendpart 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 0if not settings.base_url:print("Set ASSISTANT_BASE_URL first (see .env.example).", file=sys.stderr)return 2headers = {"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.pyreads.envtoo, and it uses the same timeout as the assistant. -
Copy the three new test files from the snapshot:
Terminal window cp ../nextia-learning-labs/C08/snapshots/end-of-m01/tests/test_*.py tests/Terminal window Copy-Item ..\nextia-learning-labs\C08\snapshots\end-of-m01\tests\test_*.py tests\Terminal window cp ../nextia-learning-labs/C08/snapshots/end-of-m01/tests/test_*.py tests/test_providers.pychecks the request key and the mock.test_config.pychecks that the key never shows and thatredactworks.test_live.pysends one live request, but only when you ask for it. -
Run the tests:
Terminal window python -m pytestThe last line:
======================= 15 passed, 1 deselected in 0.02s =======================The deselected test is the live one.
pytest.inileaves it out unless you runpython -m pytest -m live. -
Run the first command again:
python -m assistant analyse T-80008. The JSON is the same as before, and the last line isgpt-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.
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.
When the provider fails
Section titled “When the provider fails”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.
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.
ASSISTANT_PROVIDER=openai_compatible ASSISTANT_BASE_URL=http://localhost:11434/v1 ASSISTANT_MODEL=gemma3:4b python -m assistant analyse T-80008No 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.
-
Copy the template to your own
.env:Terminal window cp .env.example .envTerminal window Copy-Item .env.example .envTerminal window cp .env.example .env -
In
.env, put#before the two mock lines. Remove#from the three lines of Option 1. Save the file. -
If Ollama is not running, run
python -m assistant analyse T-80008now. You seeNo answer: Cannot reach the provider (connection error).If you have no Ollama, go to step 6. -
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 wasgemma3:4b | finish_reason: stop | 1092 tokens in, 193 out | 9.011 s. -
Run the live test:
python -m pytest -m live. The course’s run gave1 passed, 15 deselected. The other tests still use the mock:conftest.pysets the mock for every test that is not markedlive. -
Go back to the mock: delete
.env, or put the mock lines back. Runpython -m assistant analyse T-80008. The known-good line is back:… 1032 tokens in, 241 out | 2.678 s. -
Run
python -m pytest. You see15 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 sThe 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.
Your turn: the key that is never printed
Section titled “Your turn: the key that is never printed”Set a fake key for one command, and try to make the project show it. On macOS or Linux:
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.
Module check
Section titled “Module check”Answer in your own words, then compare with the example answer.
- Which code in
ticket-assistanthandles the API key? Name every place that the key passes through, and the places where it must never go. - 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
- 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.pyreads it intoSettings.api_key, which is hidden when the settings are printed.make_providergives it toOpenAICompatibleProvider, and the SDK sends it in theAuthorizationheader 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. - 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 isNo 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.
Reset to the end of Module 1
Section titled “Reset to the end of Module 1”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.
Knowledge check
Section titled “Knowledge check”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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- The application boundary splits the code into business logic, an adapter (
providers.py, the only code that calls a provider) and configuration (config.pyand.env). - Settings come from environment variables.
.envis never committed;.env.exampleis the template without secrets. - The API key lives only on the server and reaches only the adapter.
Settingsnever prints it, andredact()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 oneNo 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