9e — Production : l’exploitation (coût, budget, déploiement, SLO)

Les notebooks précédents de la série ont construit les organes : le routage multi-fournisseurs et son repli (9c), les caches exact, sémantique et de préfixe (9d). Ce dernier volet répond à la question de l’exploitant : combien ça coûte, quand prévenir, comment mettre une nouvelle version en production sans perdre la qualité mesurée, et quel niveau de service on a promis.

Toutes les mesures de ce notebook viennent du terrain réel : inférence locale et API distante, prix lus au moment de l’exécution dans le catalogue public du proxy, clés dans .env (jamais écrites ni affichées). Comme pour toute la série, le terrain est en lecture seule — on l’interroge, on ne le configure pas.

import os, json, time, random, re, statistics
from pathlib import Path
from dotenv import load_dotenv

load_dotenv(Path("..") / ".env")

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
print("OPENAI_API_KEY :", "présente" if OPENAI_API_KEY else "ABSENTE -- notebook impossible")

random.seed(20260928)   # même graine que 9c/9d : banque comparable
OPENAI_API_KEY : présente

Terrain. L’inférence locale (Ollama) et l’API directe du fournisseur sont les deux mains servie et candidate. Le catalogue du proxy donne les prix au moment de l’exécution.

# Sondages lecture seule + clients (mêmes organes que 9c/9d)
import urllib.request
from openai import OpenAI

def sonde(nom, url):
    try:
        with urllib.request.urlopen(urllib.request.Request(url), timeout=15) as r:
            n = len(json.loads(r.read().decode("utf-8")).get("data", []))
        print(f"{nom:38s} HTTP 200  {n:4d} modèles")
    except Exception as e:
        print(f"{nom:38s} INJOIGNABLE ({type(e).__name__})")

sonde("Inference locale (Ollama)", "http://127.0.0.1:11434/v1/models")

# L'API directe exige l'authentification même pour lister les modèles : sonde avec la clé du .env
req_api = urllib.request.Request(
    "https://api.openai.com/v1/models", headers={"Authorization": f"Bearer {OPENAI_API_KEY}"}
)
try:
    with urllib.request.urlopen(req_api, timeout=15) as r:
        print(f"{'API directe fournisseur':38s} HTTP 200  "
          f"{len(json.loads(r.read().decode('utf-8'))['data']):4d} modèles")
except Exception as e:
    print(f"{'API directe fournisseur':38s} INJOIGNABLE ({type(e).__name__})")

def client_distant():
    assert OPENAI_API_KEY, "OPENAI_API_KEY absente du .env"
    return OpenAI(api_key=OPENAI_API_KEY)

def client_local():
    return OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="ollama")

# Prix réels du proxy, lus au moment de l'exécution (jamais codés en dur)
req = urllib.request.Request("https://openrouter.ai/api/v1/models")
with urllib.request.urlopen(req, timeout=15) as r:
    catalogue = {m["id"]: m for m in json.loads(r.read().decode("utf-8"))["data"]}

MODELE_DISTANT_CATALOGUE = "openai/gpt-5-mini"
assert MODELE_DISTANT_CATALOGUE in catalogue
PRIX_IN = float(catalogue[MODELE_DISTANT_CATALOGUE]["pricing"]["prompt"]) * 1e6
PRIX_OUT = float(catalogue[MODELE_DISTANT_CATALOGUE]["pricing"]["completion"]) * 1e6
print(f"Prix catalogue {MODELE_DISTANT_CATALOGUE} : ${PRIX_IN:.2f}/M entrée, ${PRIX_OUT:.2f}/M sortie")
Inference locale (Ollama)              HTTP 200     2 modèles
API directe fournisseur                HTTP 200   140 modèles
Prix catalogue openai/gpt-5-mini : $0.25/M entrée, $2.00/M sortie
# Banque 9c/9d : mêmes générateurs, même graine -- deux moitiés fraîches
def q_addition(a, b):
    return f"Un entrepôt reçoit {a} colis le matin, puis {b} colis l'après-midi. Combien de colis au total ?", a + b

def q_soustraction(a, b):
    return f"Un magasin possède {a} unités de stock et en vend {b} dans la journée. Combien reste-t-il d'unités ?", a - b

def q_produit(a, b):
    return f"Une imprimerie produit {a} affiches par série et fabrique {b} séries. Combien d'affiches au total ?", a * b

GENES = [("addition", q_addition), ("soustraction", q_soustraction), ("produit", q_produit)]

rnd = random.Random(20260928)
BANQUE = []
for i in range(60):
    nom, gene = GENES[i % 3]
    if nom == "addition":
        texte, rep = gene(rnd.randint(120, 980), rnd.randint(120, 980))
    elif nom == "soustraction":
        texte, rep = gene(rnd.randint(400, 990), rnd.randint(100, 390))
    else:
        texte, rep = gene(rnd.randint(12, 96), rnd.randint(12, 96))
    BANQUE.append({"id": i, "type": nom, "question": texte, "reponse": rep})

EVAL = BANQUE[:30]     # section coût/shadow
CANARY = BANQUE[30:]   # section canary -- questions jamais vues avant
print(f"Banque : {len(BANQUE)} questions ({len(EVAL)} évaluation + {len(CANARY)} canary, disjointes)")
Banque : 60 questions (30 évaluation + 30 canary, disjointes)
def extraire_nombre(texte):
    """Dernier entier du texte rendu (organe 9c : le modèle conclut par la valeur)."""
    nombres = re.findall(r"-?\d+", texte)
    return int(nombres[-1]) if nombres else None

SYSTEME = ("Tu es un assistant de calcul. Réponds en une seule phrase courte et termine par la "
           "valeur numérique exacte, sans unité.")

def poser_local(question):
    r = client_local().chat.completions.create(
        model="qwen2.5:7b", temperature=0,
        messages=[{"role": "system", "content": SYSTEME},
                  {"role": "user", "content": question}],
    )
    return r.choices[0].message.content, r.usage

def poser_distant(question):
    r = client_distant().chat.completions.create(
        model="gpt-5-mini",   # temperature non réglable sur ce modèle (défaut 1)
        max_completion_tokens=200,
        reasoning_effort="minimal",   # sans cela, le raisonnement interne épuise le budget de tokens
        messages=[{"role": "system", "content": SYSTEME},
                  {"role": "user", "content": question}],
    )
    return r.choices[0].message.content, r.usage

def cout_usd(usage):
    return usage.prompt_tokens * PRIX_IN / 1e6 + usage.completion_tokens * PRIX_OUT / 1e6

print("Organe de réponse prêt : local (gratuit) et distant (facturé au token).")
Organe de réponse prêt : local (gratuit) et distant (facturé au token).

1. Le coût par requête, mesuré sur appels réels

Le coût d’une requête n’est pas un chiffre de documentation : c’est des tokens mesurés multipliés par des prix lus au moment de l’exécution. On fait répondre les deux modèles à la même moitié d’évaluation de la banque, et on rapporte pour chacun : justesse, latence médiane, coût par requête. La version locale n’est pas facturée au token — son coût marginal monétaire est nul (hors électricité et machine).

MESURES = {"local": [], "distant": []}
for q in EVAL:
    for nom, poser in (("local", poser_local), ("distant", poser_distant)):
        t0 = time.perf_counter()
        rep, usage = poser(q["question"])
        dt = time.perf_counter() - t0
        MESURES[nom].append({
            "type": q["type"],
            "juste": extraire_nombre(rep) == q["reponse"],
            "latence_s": dt,
            "cout_usd": cout_usd(usage) if nom == "distant" else 0.0,
        })

resume = []
for nom, m in MESURES.items():
    justes = sum(x["juste"] for x in m)
    resume.append({
        "modèle": nom,
        "justesse %": round(100 * justes / len(m), 1),
        "latence médiane (s)": round(statistics.median(x["latence_s"] for x in m), 3),
        "coût/requête ($)": round(sum(x["cout_usd"] for x in m) / len(m), 6),
        "coût total ($)": round(sum(x["cout_usd"] for x in m), 6),
    })
import pandas as pd
display(pd.DataFrame(resume))
modèle justesse % latence médiane (s) coût/requête ($) coût total ($)
0 local 86.7 1.001 0.000000 0.000000
1 distant 100.0 1.572 0.000043 0.001277

Lecture. Les deux colonnes qui décident d’un routage sont devant vous : la justesse et le coût. Le modèle local est gratuit mais se trompe sur une part des questions — c’est exactement l’écart que 9c exploitait pour router ; ici il devient le risque d’exploitation qu’il faut surveiller. Le distant est facturé au token, et son coût par requête est mesuré, pas estimé : c’est ce chiffre qui alimente le budget de la section suivante.

2. Budget : trajectoire, seuil d’alerte, date de franchissement

Un budget d’exploitation ne se décrète pas, il se simule à partir du coût mesuré. À volume fixe, la trajectoire de dépense est une droite dont la pente est le coût par requête ; le mix local/distant fait varier la pente. Le budget est mensuel et renouvelable : le compteur retombe à zéro à chaque frontière de mois, et c’est la trajectoire d’un mois qui décide — pas un cumul annuel. L’exploitant veut trois choses : cette trajectoire, le jour du mois où elle franchit le budget, et une alerte avant — posée ici à quatre-vingts pour cent du budget.

JOURS_MOIS = 30      # mois normalisé de la simulation (paramètre de l'exploitant)

def trajectoire(cout_distant_par_req, volume_par_jour, part_locale, budget_usd):
    """Dépense cumulée dans le mois courant. Le budget est mensuel RENOUVELABLE : le compteur
    retombe à zéro à chaque frontière de mois, et comme la pente est constante, la trajectoire
    d'un mois vaut pour tous. Retourne (cumuls_du_mois, jour_du_mois_80, jour_du_mois_epuisement)."""
    pente = volume_par_jour * (1 - part_locale) * cout_distant_par_req
    cumuls, c = [], 0.0
    jour80 = jour_plein = None
    for jour in range(1, JOURS_MOIS + 1):
        c += pente
        cumuls.append(c)
        if jour80 is None and c >= 0.8 * budget_usd:
            jour80 = jour
        if jour_plein is None and c >= budget_usd:
            jour_plein = jour
    return cumuls, jour80, jour_plein

COUT_DISTANT = sum(x["cout_usd"] for x in MESURES["distant"]) / len(MESURES["distant"])
VOLUME = 1500         # requêtes/jour posées au service (paramètre de l'exploitant)
BUDGET = 10.0         # budget mensuel renouvelable en dollars (paramètre de l'exploitant)

lignes = []
for part_locale in (0.0, 0.5, 0.9, 1.0):
    cumuls, j80, jp = trajectoire(COUT_DISTANT, VOLUME, part_locale, BUDGET)
    lignes.append({
        "part locale": part_locale,
        "pente ($/jour)": round(VOLUME * (1 - part_locale) * COUT_DISTANT, 4),
        "alerte 80 % (jour du mois)": j80 if j80 else "jamais",
        "budget épuisé (jour du mois)": jp if jp else "jamais",
    })
display(pd.DataFrame(lignes))
print(f"Entrées : coût distant mesuré ${COUT_DISTANT:.6f}/requête, volume {VOLUME} req/jour, "
      f"budget ${BUDGET:.0f}/mois renouvelable (compteur remis à zéro tous les {JOURS_MOIS} jours)")

# Témoins de contrôle : le modèle mensuel doit rendre les deux régimes observables.
_, j80_t, jp_t = trajectoire(0.40 / VOLUME, VOLUME, 0.0, BUDGET)   # pente à 0,40 $/jour
_, j80_z, jp_z = trajectoire(COUT_DISTANT, 0, 0.0, BUDGET)         # zéro requête posée
print(f"Témoin à $0.40/jour : alerte 80 % au jour {j80_t}, budget épuisé au jour {jp_t} du mois")
print(f"Témoin zéro requête : alerte {j80_z or 'jamais'}, épuisement {jp_z or 'jamais'}")
part locale pente ($/jour) alerte 80 % (jour du mois) budget épuisé (jour du mois)
0 0.0 0.0639 jamais jamais
1 0.5 0.0319 jamais jamais
2 0.9 0.0064 jamais jamais
3 1.0 0.0000 jamais jamais
Entrées : coût distant mesuré $0.000043/requête, volume 1500 req/jour, budget $10/mois renouvelable (compteur remis à zéro tous les 30 jours)
Témoin à $0.40/jour : alerte 80 % au jour 20, budget épuisé au jour 25 du mois
Témoin zéro requête : alerte jamais, épuisement jamais
def verifier_alerte(cumuls, budget_usd, au_jour):
    """L'organe d'alerte : dit si, au jour du mois donné, on a franchi le seuil de quatre-vingts pour cent."""
    return cumuls[au_jour - 1] >= 0.8 * budget_usd

cumuls, j80, jp = trajectoire(COUT_DISTANT, VOLUME, 0.5, BUDGET)
calme = verifier_alerte(cumuls, BUDGET, JOURS_MOIS)   # régime calme : vérifié au dernier jour du mois
cumuls_t, j80_t, _ = trajectoire(0.40 / VOLUME, VOLUME, 0.0, BUDGET)
tendu = verifier_alerte(cumuls_t, BUDGET, j80_t)      # régime tendu : au jour d'alerte calculé
print(f"Mois calme (pente mesurée), jour {JOURS_MOIS} : seuil {'franchi -- ALERTE' if calme else 'non franchi'}")
print(f"Mois tendu ($0.40/jour), jour {j80_t} : seuil {'franchi -- ALERTE' if tendu else 'non franchi'}")
Mois calme (pente mesurée), jour 30 : seuil non franchi
Mois tendu ($0.40/jour), jour 20 : seuil franchi -- ALERTE

Lecture. La pente varie d’un facteur dix selon le mix, mais aucun mix ne menace le budget : à 0,063 $/jour, le mois complet consomme moins de 2 $ des 10 $ — et le compteur retombe à zéro à chaque frontière de mois, la dépense ne s’accumule pas d’un mois sur l’autre. Le témoin à 0,40 $/jour montre l’autre régime : alerte au jour 20, budget épuisé au jour 25 du mois. C’est la lecture mensuelle qui rend ce franchissement observable — un cumul annuel le noierait deux fois (sous-alerte à pente faible, franchissement noyé à pente forte). L’alerte ne se déclenche pas sur un sentiment : c’est une fonction testée sur la trajectoire simulée dans les deux régimes, avec la pente mesurée à la section précédente — ré-exécuter le notebook avec un autre prix de catalogue et tout se recalcule.

3. Shadow : évaluer la candidate sans la servir

Avant d’exposer une nouvelle version aux utilisateurs, on la fait travailler en ombre : elle répond aux mêmes requêtes, ses réponses sont comparées, mais personne ne les voit. Le shadow de ce notebook a une propriété que l’exploitant apprécie : il réutilise les appels réels de la section coût — la candidate a déjà répondu à la moitié d’évaluation, il suffit de comparer les verdicts. Le coût du shadow est donc exactement ce qui a déjà été payé : il est nul en plus.

justesse_locale = sum(x["juste"] for x in MESURES["local"]) / len(MESURES["local"])
justesse_candidate = sum(x["juste"] for x in MESURES["distant"]) / len(MESURES["distant"])
cout_shadow = sum(x["cout_usd"] for x in MESURES["distant"])   # déjà payé à la section 1

delta = justesse_candidate - justesse_locale
verdict_shadow = "PROMOTE -- la candidate bat la version servie" if delta > 0 else "REJET"
print(f"Version servie (local)    : justesse {100*justesse_locale:.1f} %")
print(f"Candidate (distant)       : justesse {100*justesse_candidate:.1f} %")
print(f"Delta candidat - servie   : {100*delta:+.1f} points")
print(f"Coût *additionnel* du shadow : ${cout_shadow - sum(x['cout_usd'] for x in MESURES['distant']):.6f} "
      f"(les appels sont ceux, réels, de la section coût)")
print(f"Verdict du shadow : {verdict_shadow}")
Version servie (local)    : justesse 86.7 %
Candidate (distant)       : justesse 100.0 %
Delta candidat - servie   : +13.3 points
Coût *additionnel* du shadow : $0.000000 (les appels sont ceux, réels, de la section coût)
Verdict du shadow : PROMOTE -- la candidate bat la version servie

Lecture. Le shadow conclut par un verdict binaire, mais la décision d’exploitation est gradation : le delta mesuré ici décide si la candidate mérite un canary (exposition progressive) ou retour au plateau. Notez l’argument économique : réutiliser l’évaluation déjà payée est la meilleure façon de faire un shadow gratuit.

4. Canary et auto-rollback : la régression détectée sur erreurs réelles

Le canary sert la candidate à une part croissante du trafic, par fenêtres, et compare chaque fenêtre à la baseline mesurée. Si une fenêtre passe sous la baseline moins la marge, le rollback est automatique — pas de comité, pas de nuit blanche.

Ce notebook démontre la détection de manière honnête : la candidate du canary est le modèle local (le plus faible des deux, tel que mesuré) et la baseline est le distant. Les erreurs qui déclenchent le rollback sont donc de vraies erreurs de modèle sur des questions jamais vues — rien n’est simulé sur la qualité.

BASELINE = justesse_candidate          # mesurée en shadow (le distant)
MARGE = 0.10                           # 10 points sous la baseline -> rollback
TAILLE_FENETRE = 10

fenetres = []
declenche_au = None
i = 0
while i < len(CANARY):
    lot = CANARY[i:i + TAILLE_FENETRE]
    justes = 0
    for q in lot:
        rep, _ = poser_local(q["question"])
        if extraire_nombre(rep) == q["reponse"]:
            justes += 1
    j = justes / len(lot)
    rollback = j < BASELINE - MARGE
    fenetres.append({
        "fenêtre": len(fenetres) + 1,
        "questions": len(lot),
        "justesse %": round(100 * j, 1),
        "seuil rollback %": round(100 * (BASELINE - MARGE), 1),
        "rollback": rollback,
    })
    if rollback and declenche_au is None:
        declenche_au = len(fenetres)
        print(f"AUTO-ROLLBACK déclenché à la fenêtre {len(fenetres)} : "
              f"justesse {100*j:.1f} % < seuil {100*(BASELINE - MARGE):.1f} %")
        break
    i += TAILLE_FENETRE

display(pd.DataFrame(fenetres))
print("Canary arrêté." if declenche_au else "Canary terminé sans rollback : la candidate tenait la baseline.")
AUTO-ROLLBACK déclenché à la fenêtre 1 : justesse 80.0 % < seuil 90.0 %
fenêtre questions justesse % seuil rollback % rollback
0 1 10 80.0 90.0 True
Canary arrêté.

Lecture. La trace montre l’organe complet : exposition par fenêtres, comparaison au seuil dérivé de la baseline mesurée, et rollback automatique dès la première fenêtre fautive — ici déclenché par les erreurs réelles du modèle local. En production, la candidate serait la nouvelle version du service ; l’organe ne change pas d’un iota, seul le fournisseur de verdict change.

5. SLO et error budget : ce qu’on a promis, ce qu’il reste à dépenser

Un SLO de justesse transforme la qualité mesurée en contrat interne : au-dessus, tout va bien ; en dessous, on puise dans un error budget — le stock d’erreurs tolérées sur la période. Le burn rate dit à quelle vitesse on le consomme. Tout se calcule sur les mesures de ce notebook : justesse baseline, volume de la simulation de budget, fenêtre du canary.

SLO_JUSTESSE = 0.95                    # contrat interne (paramètre de l'exploitant)
JOURS_PERIODE = 30
volume_periode = VOLUME * JOURS_PERIODE          # requêtes servies sur la période
budget_erreurs = (1 - SLO_JUSTESSE) * volume_periode
erreurs_attendues_baseline = (1 - BASELINE) * volume_periode

# Burn rate sur la fenêtre canary qui a déclenché le rollback (la pire mesurée)
pire_justesse = min(f["justesse %"] for f in fenetres) / 100
burn_rate = (1 - pire_justesse) / (1 - SLO_JUSTESSE)

lignes_slo = [
    {"indicateur": "SLO de justesse promis", "valeur": f"{100*SLO_JUSTESSE:.0f} %"},
    {"indicateur": "Justesse baseline mesurée", "valeur": f"{100*BASELINE:.1f} %"},
    {"indicateur": "Error budget de la période (erreurs tolérées)", "valeur": f"{budget_erreurs:.0f} erreurs"},
    {"indicateur": "Erreurs attendues à la baseline", "valeur": f"{erreurs_attendues_baseline:.0f} erreurs"},
    {"indicateur": "Verdict budget", "valeur": "TENU" if erreurs_attendues_baseline <= budget_erreurs else "DÉPASSÉ -- geler les fonctionnalités"},
    {"indicateur": "Burn rate sur la pire fenêtre canary", "valeur": f"{burn_rate:.1f}x"},
    {"indicateur": "Lecture du burn", "valeur": "consomme le budget plus vite que prévu" if burn_rate > 1 else "dans le rythme du budget"},
]
display(pd.DataFrame(lignes_slo))
indicateur valeur
0 SLO de justesse promis 95 %
1 Justesse baseline mesurée 100.0 %
2 Error budget de la période (erreurs tolérées) 2250 erreurs
3 Erreurs attendues à la baseline 0 erreurs
4 Verdict budget TENU
5 Burn rate sur la pire fenêtre canary 4.0x
6 Lecture du burn consomme le budget plus vite que prévu

Lecture. Le verdict final est devant vous : si la baseline mesurée consomme plus d’erreurs que le contrat n’en accorde, la discipline d’exploitation est de geler les changements et de travailler la qualité — c’est le sens de l’error budget. Le burn rate de la fenêtre fautive, lui, dit à quelle vitesse une régression isolée épuiserait le stock : c’est l’alarme qui justifie l’auto-rollback de la section précédente.

Ce que la série a construit

De 9 à 9e, la série a posé les organes d’un service LLM en production : la boucle de base et son coût, le routage multi-fournisseurs avec repli, les trois caches et leurs limites mesurées, et maintenant le pilotage — budget, alerte, shadow, canary, rollback, SLO. Chaque chiffre de la série vient d’une exécution réelle sur le terrain ; ré-exécuter un notebook, c’est re-mesurer.

Exercices

Exercice 1 : alerte prédictive

L’alerte actuelle attend le franchissement du seuil. Écrivez jour_franchissement_prevu(cumuls, budget) qui extrapole la pente des premiers jours du mois pour prédire le jour du mois où le budget s’épuise — l’exploitant veut être prévenu des semaines à l’avance, pas le jour du seuil.

# TODO étudiant : extrapoler la pente (différence des cumuls) et projeter le jour de franchissement
# Indice : la pente quotidienne est cumuls[j] - cumuls[j-1] ; le reste à courir est budget - cumuls[-1]
# Étape 1 : mesurer la pente sur les premiers jours
# Étape 2 : diviser le reste à courir par la pente
# Étape 3 : retourner le jour prévu (entier) ou None si la pente est nulle
def jour_franchissement_prevu(cumuls, budget):
    print("Exercice à compléter")
    return None

resultat_ex1 = jour_franchissement_prevu(cumuls, BUDGET)
Exercice à compléter

Exercice 2 : rollback à double critère

Le rollback actuel ne regarde que la justesse. Ajoutez le critère de latence : rollback si la fenêtre passe sous le seuil de justesse ou si sa latence médiane dépasse un plafond dérivé de la baseline. Les latences des deux modèles sont déjà mesurées — servez-vous en pour calibrer le plafond.

# TODO étudiant : fenêtre de canary jugée sur justesse ET latence médiane
# Indice : statistics.median existe ; le plafond peut être un multiple de la latence baseline mesurée
# Étape 1 : relever la latence médiane baseline depuis MESURES["distant"]
# Étape 2 : poser le plafond (par exemple le double de la baseline)
# Étape 3 : écrire devrait_rollback(justesse_fenetre, latence_fenetre) -> bool
def devrait_rollback(justesse_fenetre, latence_fenetre):
    print("Exercice à compléter")
    return False

resultat_ex2 = devrait_rollback(0.5, 10.0)
Exercice à compléter

Exercice 3 : le gel automatique

Écrivez verdict_feature_freeze(burn_rate) qui décide si l’équipe doit geler les nouveautés : gel si le burn rate consomme l’error budget plus vite que la période ne le permet, sinon travaillez normalement. Testez-la sur le burn rate mesuré de la pire fenêtre du canary.

# TODO étudiant : convertir le burn rate en décision de gel
# Indice : un burn rate au-dessus de 1 signifie consommer le budget plus vite que prévu
# Étape 1 : comparer burn_rate à 1
# Étape 2 : retourner la chaîne de décision
def verdict_feature_freeze(burn_rate):
    print("Exercice à compléter")
    return None

resultat_ex3 = verdict_feature_freeze(burn_rate)
Exercice à compléter

Série GenAI Texte | Précédent : 9d — Production Caches | Suivant : 10 — LocalLlama | ↑ Sommaire série

Retour au sommet