Aller au contenu
English

La frontière de l'application

  • Leçon 4
  • 50 min
  • Module 1 : Commencer par une seule requête au modèle

Ce que vous saurez faire

Regroupez tout le code qui communique avec un fournisseur de modèles au même endroit. Gardez la clé API hors du code et du navigateur. Transformez chaque échec du fournisseur en message clair. Vous pourrez ensuite changer de fournisseur sans toucher au reste de l’assistant.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 50 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • Le projet ticket-assistant avec assistant/raw.py de la dernière leçon, .venv actif et python -m pytest qui donne 2 passed (2 tests réussis).
  • Le dossier de laboratoires ~/projects/nextia-learning-labs de Un premier essai sans compte. Vous y copierez trois fichiers de tests.
  • Facultatif, pour l’exercice du module : Ollama et gemma3:4b, comme dans la dernière leçon. Sans eux, vous verrez quand même le traitement d’un échec. C’est le but.

Vous n’avez besoin ni de compte ni de clé.

Quand vous voyagez dans un autre pays, le chargeur de votre téléphone ne convient pas à la prise murale. Vous ne reconstruisez pas le chargeur. Vous placez un petit adaptateur de voyage entre la fiche et la prise. Le téléphone et le chargeur restent les mêmes dans chaque pays. Seul l’adaptateur connaît la prise locale.

La même idée protège la clé de votre maison. Vous la gardez dans votre poche, pas collée sur la porte d’entrée. Toute personne qui accède à la porte peut utiliser une clé collée dessus.

Le problème : du code fournisseur partout et une clé au mauvais endroit

Section intitulée « Le problème : du code fournisseur partout et une clé au mauvais endroit »

Imaginez l’assistant sans frontière. Le code du service de soutien importe le SDK du fournisseur à cinq endroits. Chaque endroit lit la clé à sa façon. Puis TermeLarkfieldUne boutique en ligne d’articles pour la maison. Ce n’est pas une vraie entreprise. Tous les cours Nextia utilisent son service d’assistance et ses données comme exemple.En savoir plus L'IA dans les produits de tous les joursBases d'une carrière en IA : rôles, compétences et feuilles de route passe à un autre fournisseur, ou à un modèle local pour les tests. TermeAmiraLa développeuse de Larkfield. Elle écrit le code des outils du service d’assistance dans les cours. doit trouver et modifier chaque endroit, puis les tester à nouveau.

TermeTomásL’ingénieur en IA de Larkfield. Il propose souvent un raccourci rapide, et la leçon montre pourquoi il échoue. propose une solution plus rapide. « Pourquoi nous faut-il un serveur? L’application de soutien tourne dans le navigateur de l’agent. Mettons la clé API dans le JavaScript de la page et appelons le fournisseur à partir de là. » Cela fonctionne dans une démonstration. La section « Le raccourci de Tomás » ci-dessous montre ce que n’importe qui peut alors faire avec la clé.

Il y a un troisième problème : le fournisseur échoue parfois. Il est lent, refuse une clé ou reste inaccessible. Sans plan, chaque échec affiche une longue trace d’erreur Python au milieu de l’écran de l’agent.

La frontière de l’application sépare votre application de quelque chose que vous ne contrôlez pas, ici le fournisseur. Derrière cette frontière, le code se divise en trois parties. Chacune a un seul rôle :

  • La logique d’affaires lit le billet de soutien, construit la requête et utilise la réponse. Elle ne connaît rien aux fournisseurs.
  • L’adaptateur est le seul code qui communique avec un fournisseur. Comme l’adaptateur de voyage, il transforme un appel standard, complete(request), en appel propre au fournisseur. Il transforme aussi les erreurs du fournisseur en erreurs propres à l’assistant.
  • La configuration précise le fournisseur, le modèle, l’adresse, la clé et le temps d’attente. Elle vient des TermeVariable d’environnementUne valeur nommée que le système donne à un programme au démarrage, par exemple une clé d’API. La valeur reste hors du code et hors de Git.En savoir plus Configuration et secretsPython pour l'ingénierie de l'IA en pratique, pas du code.
Dans une zone en pointillés, le serveur de Larkfield, digne de confiance : la logique d'affaires (__main__.py, context.py) envoie une requête, un dictionnaire, à l'adaptateur (providers.py : MockProvider, OpenAICompatibleProvider, le seul code qui appelle un fournisseur et transforme ses erreurs en ProviderError). La configuration (config.py et .env, jamais inclus dans un commit : fournisseur, modèle, URL de base, clé API, délai d'expiration) donne la clé uniquement à l'adaptateur. L'adaptateur lit les enregistrements pour la simulation. Hors de la zone : l'application de soutien dans le navigateur de l'agent, sans clé, envoie le billet à la logique d'affaires; l'adaptateur envoie une requête HTTPS avec la clé au fournisseur : Azure, OpenAI ou Ollama.
La frontière de l'application ticket-assistant à la fin de ce module. La clé passe de la configuration à l'adaptateur, et nulle part ailleurs.
Description textuelle du diagramme

Une zone en pointillés délimite le serveur de Larkfield, qui est digne de confiance. Elle contient quatre parties. La logique d’affaires, dans main.py et context.py, lit le billet, construit la requête et utilise la réponse. Elle transmet la requête, un dictionnaire Python, à l’adaptateur dans providers.py. L’adaptateur contient MockProvider et OpenAICompatibleProvider. C’est le seul code qui appelle un fournisseur, et il transforme les erreurs du fournisseur en ProviderError. La configuration comprend config.py et le fichier .env, qui n’est jamais inclus dans un commit (enregistrement de modifications). Elle contient le fournisseur, le modèle, l’URL de base, la clé API et le délai d’expiration. Elle donne la clé uniquement à l’adaptateur. Pour la simulation, l’adaptateur lit les enregistrements. Hors de la zone, à gauche, l’application de soutien tourne dans le navigateur de l’agent et n’a aucune clé. Elle envoie le billet au serveur. À droite, l’adaptateur envoie des requêtes HTTPS avec la clé au fournisseur : Azure, OpenAI ou Ollama sur votre ordinateur.

Pourquoi cette séparation aide : chaque changement a un seul endroit où aller. Un nouveau fournisseur nécessite une nouvelle classe d’adaptateur. Un nouveau modèle nécessite une ligne dans .env. Le code qui utilise la réponse ne change dans aucun des deux cas.

Un petit exemple détaillé : des paramètres qui cachent la clé

Section intitulée « Un petit exemple détaillé : des paramètres qui cachent la clé »

Voici les paramètres et le choix de l’adaptateur en action, dans une session Python du projet terminé. La clé est une valeur de test inventée.

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"))

La sortie :

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

Quatre points à observer. Afficher les paramètres montre tout sauf la clé. make_provider choisit l’adaptateur selon les paramètres, et les paramètres par défaut donnent la simulation. redact masque une clé, une adresse courriel et un numéro de carte avant d’envoyer le texte à un journal. L’identifiant de commande reste, car le journal en a besoin. Les adresses de vos objets (0x…) seront différentes.

L’adaptateur reçoit un deuxième fournisseur, OpenAICompatibleProvider. Il appelle tout serveur qui prend en charge l’API TermeChat CompletionsLe format de requête et de réponse de l’API de clavardage d’OpenAI : un nom de modèle, une liste de messages avec des rôles, et des paramètres. Azure, Ollama et beaucoup d’autres fournisseurs acceptent le même format (« compatible avec OpenAI »), donc un seul SDK fonctionne avec tous.En savoir plus Les éléments d'une requêteCréer des applications fiables avec les API de LLM compatible avec OpenAI, au moyen du SDK openai. Ouvrez assistant/providers.py et faites quatre changements.

  1. Dans la chaîne de documentation en haut, remplacez la phrase sur le fournisseur unique :

    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. Ajoutez deux importations pour obtenir les lignes suivantes :

    assistant/providers.py (imports)
    import contextlib
    import hashlib
    import json
    import time
    from dataclasses import dataclass, field
    from pathlib import Path
  3. Après la classe ProviderError, ajoutez deux autres erreurs :

    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. À la fin du fichier, ajoutez la traduction des erreurs et le nouveau fournisseur :

    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))

Ces lignes contiennent trois décisions. D’abord, un délai d’expiration : le temps maximal pendant lequel le code attend une réponse. Ici, c’est 30 secondes, sauf si les paramètres indiquent autre chose. Sans ce délai, une connexion bloquée peut immobiliser l’écran de l’agent pendant plusieurs minutes. Ensuite, max_retries=0. Le SDK réessaie certaines erreurs de lui-même. Mais une nouvelle tentative répète une requête, et cette répétition implique une décision sur le temps et l’argent. Le module 6 prend cette décision dans retry.py; le SDK ne doit donc pas la prendre. Enfin, translate_errors transforme chaque erreur du SDK en ProviderError. Le reste de l’assistant gère un seul type d’erreur et n’importe jamais openai. TermeOllamaUn programme gratuit qui télécharge des modèles ouverts et les fait fonctionner sur votre ordinateur. Votre code l’appelle à une adresse locale, comme une API hébergée.En savoir plus Modèle hébergé ou autohébergéLes LLM : comment ils fonctionnent et comment en choisir un n’a pas besoin de clé. La valeur "not-needed" remplit donc l’argument obligatoire du SDK.

Créez le fichier assistant/config.py. Il lit les paramètres dans les variables d’environnement et dans le fichier .env du dossier du projet, s’il existe.

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
Partie Son rôle
load_env_file() Lit .env. Une variable déjà définie par l’interpréteur de commandes a priorité. Une commande ponctuelle peut donc changer un paramètre.
Settings Regroupe tous les paramètres dans un objet. frozen=True : rien ne les modifie par accident. repr=False sur api_key : afficher ou journaliser les paramètres ne révèle jamais la clé.
make_provider() Le seul endroit qui choisit l’adaptateur. La simulation est le choix par défaut. Le projet fonctionne donc sans aucun fichier .env.
redact() Masque les clés, les numéros de carte et les adresses courriel avant d’envoyer le texte à un journal. Une liste de motifs sert de protection : elle détecte les formes courantes, pas tous les secrets.

Créez le fichier .env.example dans le dossier du projet. Il sert de modèle pour votre propre .env et ne contient aucun secret. Vous pouvez donc l’inclure sans risque dans un 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)

Le fichier .gitignore du projet contient déjà .env et conserve .env.example grâce à la ligne !.env.example. Git ne prend donc jamais votre vrai .env, même avec git add ..

Étape 4 : la ligne de commande et raw.py utilisent les paramètres

Section intitulée « Étape 4 : la ligne de commande et raw.py utilisent les paramètres »
  1. Remplacez assistant/__main__.py par cette version. Elle demande le fournisseur à la configuration et transforme un échec du fournisseur en une seule ligne claire :

    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())

    La ligne de résumé ne dit plus « au moment de l’enregistrement » : avec un fournisseur réel, le temps est réel.

  2. Dans assistant/raw.py, utilisez aussi les paramètres. Supprimez import os et ajoutez from .config import Settings juste avant from .context import build_request. Modifiez ensuite main pour que ses premières lignes et sa partie --send soient les suivantes :

    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)

    Le reste du fichier ne change pas. Maintenant, raw.py lit aussi .env et utilise le même délai d’expiration que l’assistant.

  3. Copiez les trois nouveaux fichiers de tests depuis l’instantané :

    Fenêtre de terminal
    cp ../nextia-learning-labs/C08/snapshots/end-of-m01/tests/test_*.py tests/

    test_providers.py vérifie la clé de la requête et la simulation. test_config.py vérifie que la clé n’apparaît jamais et que redact fonctionne. test_live.py envoie une requête réelle, mais seulement quand vous le demandez.

  4. Lancez les tests :

    Fenêtre de terminal
    python -m pytest

    La dernière ligne :

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

    Le test exclu est celui avec le fournisseur réel. pytest.ini l’exclut, sauf si vous lancez python -m pytest -m live.

  5. Relancez la première commande : python -m assistant analyse T-80008. Le JSON reste le même, et la dernière ligne est gpt-6-luna-2026-09-22 | finish_reason: stop | 1032 tokens in, 241 out | 2.678 s.

Votre projet correspond maintenant à l’instantané end-of-m01. Sur macOS et Linux, vérifiez-le avec diff -r -x .venv -x __pycache__ -x .pytest_cache ../nextia-learning-labs/C08/snapshots/end-of-m01 . : aucune sortie signifie aucune différence.

L’idée de Tomás supprime le serveur. Le navigateur de l’agent appelle directement le fournisseur, avec la clé dans le JavaScript de la page. Le schéma compare cette approche à celle d’Amira.

En haut : le raccourci de Tomás, la clé dans la page. Le navigateur de l'agent, dont le JavaScript contient la clé, envoie une requête HTTPS avec Authorization : Bearer et la clé directement au fournisseur. Note en pointillés : outils de développement, onglet Network (réseau) : toute personne qui ouvre la page voit l'en-tête, copie la clé et dépense l'argent de Larkfield. En bas : la conception d'Amira, la clé reste sur le serveur. Le navigateur de l'agent, sans clé, envoie le billet et la session de l'agent au serveur de Larkfield. Ce serveur conserve la clé dans une variable d'environnement et envoie une requête HTTPS avec la clé au fournisseur.
L'endroit où se trouve la clé détermine qui peut l'utiliser. Toute personne qui peut ouvrir la page voit le code exécuté dans le navigateur.
Description textuelle du diagramme

Deux conceptions. Dans celle du haut, le raccourci de Tomás, le JavaScript de la page dans le navigateur de l’agent contient la clé. Le navigateur envoie la requête avec « Authorization : Bearer » et la clé directement au fournisseur. Une note indique que l’onglet Network (réseau) des outils de développement montre cet en-tête à toute personne qui ouvre la page. Elle peut copier la clé et dépenser l’argent de Larkfield. Dans celle du bas, la conception d’Amira, le navigateur n’a aucune clé. Il envoie le billet et la session de l’agent au serveur de Larkfield. Le serveur conserve la clé dans une variable d’environnement et envoie la requête, avec la clé, au fournisseur.

Erreur courante

La clé dans le navigateur

Symptôme : la démonstration fonctionne et rien ne semble anormal. Le danger reste invisible jusqu’à ce que quelqu’un regarde. Un visiteur ouvre la page de soutien, appuie sur F12 pour ouvrir les outils de développement et sélectionne l’onglet Network. Dans la requête au fournisseur, les en-têtes affichent Authorization: Bearer (autorisation par jeton porteur), suivi de la clé complète. La même clé se trouve dans le fichier JavaScript de la page, visible dans l’onglet Sources (sources). Ceci décrit ce que le navigateur montre. Le cours n’a pas publié de page contenant une vraie clé.

Cause : le navigateur peut montrer tout ce qu’il exécute. Le code d’une page n’est pas secret, et le code minifié ou caché reste lisible. Une clé dans une page est une clé collée sur la porte d’entrée. Quiconque la copie peut envoyer n’importe quelle requête sur le compte de Larkfield, aux frais de Larkfield. Cela dure jusqu’à ce que quelqu’un le remarque et révoque la clé.

Correction : gardez la clé sur le serveur, dans une variable d’environnement, et donnez-la uniquement à l’adaptateur. Le navigateur communique avec le serveur de Larkfield, qui vérifie l’identité de l’agent, puis appelle le fournisseur. Si une clé s’est déjà trouvée dans une page, un journal ou un commit, considérez-la comme divulguée. Créez une nouvelle clé chez le fournisseur et supprimez l’ancienne. Supprimer le fichier ne suffit pas, car des copies peuvent exister.

Ce que cela signifie pour un vrai service d’IA : le serveur intermédiaire n’est pas une charge inutile. C’est l’endroit où conserver la clé, puis appliquer les contrôles de ce cours : le schéma, les permissions et le budget. Un navigateur ne peut faire respecter aucun de ces contrôles.

L’adaptateur gère maintenant trois types d’échec. Un délai d’expiration : aucune réponse à temps. Une TermeLimite de débitLe maximum qu’un client peut envoyer à un fournisseur pendant une période, par exemple 100 000 jetons et 100 requêtes par minute. Au-delà, le fournisseur répond HTTP 429 (Too Many Requests) et ne fait aucun travail.En savoir plus Gérer les limites du fournisseurCréer des applications fiables avec les API de LLM : HTTP 429, trop de requêtes (le module 6 la gère). Et toutes les autres erreurs : une mauvaise clé, une requête refusée, une erreur du serveur ou aucune connexion. Chacune devient une ProviderError. La ligne de commande affiche une ligne commençant par No answer: (aucune réponse) et se termine avec le code 1.

Un cheminement de décision. L'adaptateur appelle le fournisseur, avec le délai d'expiration ASSISTANT_TIMEOUT_S. Qu'est-ce qui revient? Une réponse : une Completion, affichée, code de sortie 0. Rien à temps : ProviderTimeout. HTTP 429 : RateLimited avec Retry-After. HTTP 400, 401 ou 5xx : ProviderError avec le statut. Aucune connexion : ProviderError, fournisseur inaccessible. Les quatre erreurs mènent au même résultat : la ligne de commande affiche No answer : … et se termine avec 1, sans trace d'erreur ni clé.
Toutes les façons dont un appel peut se terminer. Quatre types d'échec deviennent un message clair, et la clé n'y apparaît jamais.
Description textuelle du diagramme

L’adaptateur appelle le fournisseur et attend au plus ASSISTANT_TIMEOUT_S secondes. Si une réponse revient, elle devient une Completion que la commande affiche, avec le code de sortie 0. Si rien ne revient à temps, l’adaptateur lève ProviderTimeout. HTTP 429 devient RateLimited, avec le temps d’attente de l’en-tête Retry-After. HTTP 400, 401 ou une erreur serveur 5xx devient une ProviderError avec le statut. Une connexion échouée devient une ProviderError qui indique que le fournisseur est inaccessible. Les quatre cas se terminent de la même façon. La commande affiche « No answer : » avec la raison et se termine avec le code 1, sans trace d’erreur ni clé.

Voici des échecs réels du projet terminé. Le premier ne nécessite rien : l’adresse pointe vers Ollama, mais Ollama ne tourne pas.

Fenêtre de terminal
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).

Une mauvaise clé, envoyée à Termechat-smallLe nom que le code de Larkfield donne à un petit modèle hébergé peu coûteux : gpt-6-luna sur Azure. Le code utilise ce nom, donc le modèle peut changer sans modifier le code.En savoir plus Ce qu'est une API de LLMCréer des applications fiables avec les API de LLM sur 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.'}}

Et un délai d’expiration de 1 seconde, ASSISTANT_TIMEOUT_S=1. chat-small prend environ 3 secondes, et un modèle local encore en chargement prend plus de temps. Les deux ont donné le même résultat réel :

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

Aucun de ces messages ne contient la clé. Le message 401 reprend le texte du fournisseur. Ce texte explique le problème sans montrer la clé envoyée. Choisissez le délai d’expiration selon les latences réelles. Pour les billets du cours, chat-small a pris 2,72 secondes à la TermeMédianeLa valeur du milieu quand on trie les valeurs. La moitié est plus basse, l’autre plus haute. Un seul appel très lent la change peu : c’est un bon temps « habituel ».En savoir plus Mesurer la performance en pratiqueLes LLM : comment ils fonctionnent et comment en choisir un et 4,72 secondes pour les 5 % les plus lents. Un délai de 30 secondes laisse une marge pour les ralentissements, tout en libérant l’écran de l’agent.

Exercice du module : du simulateur à un modèle local, puis retour

Section intitulée « Exercice du module : du simulateur à un modèle local, puis retour »

Cet exercice utilise .env, comme un vrai déploiement. Il montre la frontière en action : vous changez de fournisseur sans modifier une seule ligne de code.

  1. Copiez le modèle dans votre propre fichier .env :

    Fenêtre de terminal
    cp .env.example .env
  2. Dans .env, ajoutez # devant les deux lignes du simulateur. Retirez # des trois lignes de l’option 1. Enregistrez le fichier.

  3. Si Ollama ne fonctionne pas, exécutez maintenant python -m assistant analyse T-80008. Vous voyez No answer: Cannot reach the provider (connection error). (aucune réponse : impossible de joindre le fournisseur, erreur de connexion). Si vous n’avez pas Ollama, passez à l’étape 6.

  4. Démarrez Ollama et exécutez la commande de nouveau. La réponse vient de TermeGemma 3 4BUn petit modèle ouvert de Google, avec 4 milliards de paramètres. Il peut fonctionner sur un portable avec Ollama, sans compte ni coût par appel.En savoir plus Modèle hébergé ou autohébergéLes LLM : comment ils fonctionnent et comment en choisir un. Lors de l’essai du cours, elle était entourée de délimiteurs de bloc TermeMarkdownUne façon de noter la mise en forme en texte simple, par exemple **gras** ou # pour un titre. Les modèles l’ajoutent souvent : un programme qui attend du JSON simple doit l’enlever ou refuser la réponse.. La commande a donc affiché le texte et (This answer is not valid JSON.) (cette réponse n’est pas du JSON valide). La dernière ligne était gemma3:4b | finish_reason: stop | 1092 tokens in, 193 out | 9.011 s (modèle, raison de l’arrêt, jetons en entrée et en sortie, durée).

  5. Exécutez le test avec le fournisseur réel : python -m pytest -m live. L’essai du cours a donné 1 passed, 15 deselected (1 test réussi, 15 non sélectionnés). Les autres tests utilisent toujours le simulateur : conftest.py configure le simulateur pour chaque test qui n’est pas marqué live (réel).

  6. Revenez au simulateur : supprimez .env, ou remettez les lignes du simulateur. Exécutez python -m assistant analyse T-80008. La ligne attendue revient : … 1032 tokens in, 241 out | 2.678 s (jetons en entrée et en sortie, durée).

  7. Exécutez python -m pytest. Vous voyez 15 passed, 1 deselected (15 tests réussis, 1 non sélectionné).

Afficher la réponse complète de Gemma 3 4B lors de l’essai du cours

Résultat enregistré avec Ollama 0.40.1, à partir du projet terminé :

```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

L’équipe est la bonne, mais needs_human vaut vrai pour une simple question de livraison. De plus, la réponse n’est pas du JSON valide. Les prochains modules traitent ces deux problèmes : le module 2 avec la requête, le module 3 avec la vérification.

Définissez une fausse clé pour une seule commande, puis essayez de faire afficher cette clé par le projet. Sur macOS ou Linux :

Fenêtre de terminal
ASSISTANT_API_KEY=test-key-1234567890abcdef python -c "from assistant.config import Settings; print(Settings.from_env())"

Avant de l’exécuter, prédisez la sortie. Ensuite, cherchez test-key dans toute la sortie.

Afficher la réponse

La sortie affiche les paramètres sans la clé : Settings(provider='mock', model='chat-small', base_url='', timeout_s=30.0, reasoning_effort=''). repr=False exclut api_key du texte affiché, donc test-key n’apparaît nulle part. La clé reste dans settings.api_key, car l’adaptateur en a besoin. Masquer la clé à l’affichage protège les journaux (logs), où surviennent la plupart des fuites. Cela ne rend pas la valeur secrète à l’intérieur du programme.

Répondez dans vos propres mots, puis comparez avec l’exemple de réponse.

  1. Quel code dans ticket-assistant gère la clé d’API? Nommez tous les endroits où la clé passe et ceux où elle ne doit jamais aller.
  2. Que se passe-t-il quand le fournisseur échoue? Prenez trois cas : le fournisseur ne fonctionne pas, la clé est incorrecte et le fournisseur est trop lent.
Afficher un exemple de réponse
  1. La clé commence dans une variable d’environnement, ASSISTANT_API_KEY, définie dans l’interpréteur de commandes ou dans .env. Git ignore .env. Seul .env.example, sans clé, fait l’objet d’un commit (enregistrement dans le dépôt). config.py la lit dans Settings.api_key, qui reste masqué lors de l’affichage des paramètres. make_provider la transmet à OpenAICompatibleProvider, et le SDK l’envoie dans l’en-tête Authorization par HTTPS. Aucun autre code ne la voit. Elle ne doit jamais se trouver dans le code, un commit, un journal, un message d’erreur ou un navigateur.
  2. L’adaptateur transforme chaque échec en ProviderError. La ligne de commande affiche une seule ligne et se termine avec le code 1. Fournisseur arrêté : No answer: Cannot reach the provider (connection error). Clé incorrecte : No answer: HTTP 401: … (aucune réponse : HTTP 401), avec le message du fournisseur, sans clé. Trop lent : le délai maximal met fin à l’attente. Le message est No answer: The provider did not answer within the timeout. (aucune réponse : le fournisseur n’a pas répondu dans le délai maximal). L’adaptateur ne réessaie pas, car les nouvelles tentatives du SDK sont désactivées. Le module 6 ajoute des nouvelles tentatives avec des limites.

Si votre projet n’obtient pas 15 passed, 1 deselected, copiez l’instantané end-of-m01 en suivant les étapes de Réinitialiser le projet. Utilisez end-of-m01 au lieu de start dans la commande de copie. Le module 2 commence à partir de cet instantané.

Vérifiez votre compréhension

Larkfield passe de chat-small sur Azure à un modèle d'un autre fournisseur avec une API compatible avec OpenAI. Quels fichiers changent et lesquels restent identiques?

Afficher la réponse

Aucun fichier Python ne change. Le nouveau fournisseur utilise la même API, donc OpenAICompatibleProvider fonctionne avec lui. Seule la configuration change : ASSISTANT_BASE_URL, ASSISTANT_MODEL et ASSISTANT_API_KEY dans .env ou dans l’environnement du serveur. Si le nouveau fournisseur avait une API différente, il faudrait une nouvelle classe d’adaptateur dans providers.py. make_provider la choisirait. La logique d’affaires ne change jamais.

Vérifiez votre compréhension

Tomás dit : « La page est réservée à nos agents, avec une connexion obligatoire. Donc, la clé dans le JavaScript est en sécurité. » Que répondez-vous?

Afficher la réponse

Chaque agent qui se connecte peut ouvrir les outils de développement et copier la clé. Toute personne qui accède à l’ordinateur portable ou à la session d’un agent peut aussi le faire. La connexion contrôle qui voit la page, pas ce que la page révèle. Avec la clé, une personne peut appeler le fournisseur directement, sans passer par l’application de soutien. Aucun contrôle ne s’applique, et Larkfield paie les frais. La clé doit rester sur le serveur, derrière la connexion obligatoire, jamais dans la page.

Vérifiez votre compréhension

Prédisez le résultat : vous définissez ASSISTANT_TIMEOUT_S=60 et le fournisseur est en panne. Que voit l'agent, et après combien de temps?

Afficher la réponse

Cela dépend du type de panne du fournisseur. Si la connexion est refusée immédiatement, l’agent voit « No answer : Cannot reach the provider (connection error). » (aucune réponse : impossible de joindre le fournisseur, erreur de connexion) après environ une seconde. Si le fournisseur accepte la connexion, mais ne répond jamais, l’agent attend les 60 secondes complètes. Ensuite, l’agent voit « No answer : The provider did not answer within the timeout. » (aucune réponse : le fournisseur n’a pas répondu dans le délai maximal). Le délai maximal représente donc la plus longue attente que vous acceptez. Choisissez-le à partir des latences réelles, plutôt que de le rendre aussi grand que possible.

Récapitulatif

  • La frontière de l’application sépare le code en logique d’affaires, adaptateur et configuration. L’adaptateur (providers.py) est le seul code qui appelle un fournisseur. La configuration se trouve dans config.py et .env.
  • Les paramètres viennent des variables d’environnement. .env ne fait jamais l’objet d’un commit; .env.example sert de modèle sans secrets.
  • La clé d’API reste uniquement sur le serveur et atteint seulement l’adaptateur. Settings ne l’affiche jamais. redact() masque les clés, les numéros de carte et les adresses courriel avant l’envoi du texte à un journal. Une clé dans un navigateur est une clé divulguée.
  • Chaque échec du fournisseur devient un ProviderError, et la commande affiche une seule ligne No answer: (aucune réponse). Un délai maximal limite l’attente. Les nouvelles tentatives du SDK sont désactivées, donc le module 6 décide des nouvelles tentatives.
  • Passer du simulateur à un modèle local, puis revenir, ne demande aucun changement de code : seulement de .env.

Les modèles et les API changent vite; les chiffres de ce module représentent un instantané. La frontière, elle, reste. Ensuite, le module 2 traite la requête comme une spécification que vous pouvez tester.

Leçon suivante : Préciser la tâche