Model stack Chapitre 15 / 42

OpenRouter & le modèle par agent

L'usine branche sa passerelle : une seule clé pour tous les moteurs du roster, et un port qui traduit chaque ligne model/thinking en route facturée.

Hier, la jauge des moteurs a relevé les prix du jour sur le registre. Posez-vous la question qu’elle ne pose pas : quand le builder tourne sur z-ai/glm-5.3, qui encaisse, avec quelle clé ? Jusqu’ici, la réponse dépendait de votre poste : la résolution du modèle reposait sur les fournisseurs et les clés que votre harnais se trouvait avoir. Quatre agents pouvaient signifier quatre comptes, quatre clés, quatre factures, et un roster qui ne tournait chez un collègue que s’il avait la même collection de clés. À la fin de ce chapitre, toute l’usine passera par une seule porte de facturation : la passerelle de modèles, une clé dans .env, tous les moteurs du roster. Vous saurez exactement comment une ligne model: du YAML devient une requête routée, servie et facturée. La pièce du jour complète celle d’hier : la jauge lisait le registre, aujourd’hui le port et le roster parlent sa langue.

OpenRouter : une clé, tous les modèles

L’idée en une phrase

La passerelle de modèles (OpenRouter dans ce livre) est un point d’entrée unique vers des centaines de moteurs multi-fournisseurs : une clé (OPENROUTER_API_KEY), une facture, et le registre que votre jauge lit depuis hier comme catalogue. La pièce vit entièrement côté code déterministe : une clé posée dans .env, chargée par le port, jamais commitée.

Points clés

  • Une clé, une facture, tout le catalogue. Un compte, quelques dollars de crédits, une clé d’API : chaque fournisseur du roster (Anthropic, Z.ai, DeepSeek, Google) devient une ligne sur le même relevé. Le registre public que la jauge du chapitre 14 interroge est le catalogue de cette passerelle : votre roster en parlait déjà la langue sans le savoir.
  • Un identifiant est un marché, pas une machine. Le même deepseek/deepseek-v4-flash-0731 est servi, au relevé du 31 août 2026, par une vingtaine de routes entre ~0,03 et ~0,44 $/M en entrée, heures creuses, quantifications et promotions comprises. La passerelle choisit la route. Le prix du registre est un repère, la facture dépend de la route servie.
  • La clé vit dans .env, et seul le port la lit. Le .gitignore du chapitre 1 couvre .env depuis le premier jour, et le plan réservait la ligne OPENROUTER_API_KEY. Aujourd’hui elle se pose. Détail qui compte : les harnais ne chargent pas .env d’eux-mêmes, c’est le port qui le lit et le transmet à l’environnement des agents.
  • La commodité a un prix, dites-le. La passerelle prélève une commission de l’ordre de quelques pourcents à l’achat des crédits. Ce que vous achetez avec : le failover entre fournisseurs, la facture consolidée, et le droit de changer de moteur sans ouvrir un compte.
  • La passerelle est une simplification, pas une obligation. Les API directes des fournisseurs restent utilisables (l’adaptateur Claude Code reste natif Anthropic, par exemple). L’usine standardise la route par défaut, elle ne ferme pas les autres.

Exemple concret

Comptez ce que le roster du chapitre 13 exigerait en direct : DeepSeek pour le scout et le documenter, Anthropic pour le planner, Z.ai pour le builder, Google pour le reviewer. Quatre comptes à ouvrir, quatre clés à protéger, quatre seuils de facturation à surveiller, et un .env de collègue qui ne ressemble jamais au vôtre. Avec la passerelle : un compte, 10 $ de crédits, une clé. Le SDLC d’hier, ~40 à 60 centimes, apparaît ligne par ligne dans un seul tableau d’activité, agent par agent, modèle par modèle. Et quand le chapitre 16 branchera Kimi ou Grok, l’opération coûtera exactement une ligne de YAML : le compte, lui, existe déjà.

Avec et sans passerelle, pour le roster du chapitre 13

AspectEn direct, fournisseur par fournisseurPar la passerelle
Clés à gérerune par fournisseur (quatre aujourd’hui)une seule
Facturequatre relevés, quatre seuilsun relevé, agent par agent
Nouveau moteur (ch. 16)ouvrir un compte, poser une cléchanger une ligne model:
Indisponibilité d’un fournisseurle run meurtfailover vers une autre route
Prixle tarif du fournisseurle marché des routes, commission comprise

Config — la clé, posée une fois

Deux lignes de modèle versionnées, une copie locale jamais commitée. Sous PowerShell comme sous bash, la copie est la même commande d’une ligne :

# .env.sample — versionne : la forme, jamais la valeur
# Copiez-le en .env (couvert par le .gitignore du ch. 1) puis collez votre cle :
#   cp .env.sample .env
OPENROUTER_API_KEY=

Piège courant : « le prix affiché au registre est LE prix » est inexact. Un identifiant est servi par plusieurs fournisseurs à des prix différents, et la facture dépend de la route que la passerelle a choisie au moment de la requête. Le registre donne l’ordre de grandeur et l’existence, la vérité comptable est dans le relevé d’activité, requête par requête. La jauge d’hier reste votre boussole, pas votre reçu.


Modèle et thinking par agent du roster

L’idée en une phrase

La promesse du chapitre 10 s’achève aujourd’hui : chaque ligne model: et thinking: du roster traverse le port qui la traduit en dialecte du harnais et en route de passerelle. Le roster continue de parler le langage du registre (la jauge le vérifie tel quel), et c’est l’adaptateur pi qui préfixe la route openrouter/, côté code déterministe, sans qu’aucun script ni aucun agent n’en sache rien.

Points clés

  • Le roster parle le langage du registre, c’est un invariant. La même chaîne z-ai/glm-5.3 vit dans le YAML, dans la sortie de la jauge et sur la facture. Le préfixe openrouter/ est un détail de dialecte, et les dialectes vivent dans les adaptateurs, jamais dans vos fichiers de config.
  • Côté pi, la passerelle est un fournisseur intégré. --model openrouter/z-ai/glm-5.3 résout contre le catalogue de la passerelle et s’authentifie avec OPENROUTER_API_KEY, la clé que le port a chargée depuis .env. pi --list-models openrouter montre ce que votre harnais voit du catalogue.
  • thinking voyage avec le modèle, et se paie en sortie. L’échelle offmax du roster devient un effort de raisonnement transmis par la passerelle au moteur. Sur un modèle sans raisonnement, le réglage est inerte : pas d’erreur, pas d’effet. Les jetons de réflexion sont facturés au prix de sortie, et high sur le planner frontier est un choix assumé, chiffré au chapitre 14.
  • Claude Code a deux régimes. Natif : sa propre authentification Anthropic, des modèles Anthropic, le régime par défaut de l’usine. Passerelle : trois variables d’environnement le pointent sur OpenRouter, mais la compatibilité n’est garantie que sur les modèles Anthropic. C’est pourquoi l’adaptateur polyglotte de l’usine reste pi.
  • La chaîne est complète, le module peut s’ouvrir en grand. Chaque agent son moteur, chaque phase son prix, une seule clé : le chapitre 16 branchera les frontier et les open weights, le chapitre 17 rendra les rosters interchangeables et mesurables.

Exemple concret

Suivez la ligne du builder pendant un build. Le YAML dit model: z-ai/glm-5.3, thinking: high. agent_action (chapitre 11) glisse ces valeurs dans la HarnessRequest, l’adaptateur pi émet --model openrouter/z-ai/glm-5.3 --thinking high, la passerelle authentifie avec la clé unique et choisit une route parmi la quinzaine de fournisseurs qui servent GLM 5.3 ce jour-là (la plupart à ~1,40 $/M en entrée, ~4,40 en sortie au relevé du 31 août 2026), et le moteur travaille. Au retour, l’usage et le coût du tour remontent par le flux JSONL, l’adaptateur les cumule, et HarnessResult.cost_usd tombe dans la trace du run : ~15 à 20 centimes pour la phase. Nombre de scripts modifiés depuis le chapitre 11 pour obtenir tout cela : zéro. La traduction vit dans l’adaptateur, comme promis.

Le voyage d’une ligne du roster

ÉtapeQuiForme
déclarationfactory.config.yaml (code)model: z-ai/glm-5.3 + thinking: high
traductionl’adaptateur pi du port (code)--model openrouter/z-ai/glm-5.3 --thinking high
routage + facturationla passerelleune route choisie parmi les fournisseurs du jour
travaille moteur (agent)la seule étape agentique de la chaîne
retourflux JSONL → port (code)usage et coût cumulés dans HarnessResult.cost_usd

Commande — les deux dialectes derrière le port

La version pi est celle que le port émet désormais pour chaque agent du roster. La version Claude Code est un régime optionnel du harnais entier (trois variables d’environnement, pas un drapeau par appel) que l’usine documente sans l’adopter par défaut :

# version pi — la route passerelle, prefixee par l'adaptateur du port
pi -p --model openrouter/z-ai/glm-5.3 --thinking high "Bonjour l'usine"
pi --list-models openrouter   # ce que votre harnais voit du catalogue

# version Claude Code — pointer LE HARNAIS ENTIER sur la passerelle (optionnel)
# Trois variables dans votre profil shell (PowerShell : $env:NOM = "...") :
#   ANTHROPIC_BASE_URL=https://openrouter.ai/api
#   ANTHROPIC_AUTH_TOKEN=<votre cle OpenRouter>
#   ANTHROPIC_API_KEY=            <- vide, EXPLICITEMENT : sinon conflit d'auth
# Compatibilite garantie sur les modeles Anthropic uniquement — l'adaptateur
# polyglotte de l'usine reste pi ; claude reste natif Anthropic par defaut.

Piège courant : « autant écrire openrouter/z-ai/glm-5.3 directement dans le roster » casse l’invariant du jour. La jauge du chapitre 14 chercherait cet identifiant dans le registre, le déclarerait absent et sortirait en échec. Et le jour où vous changerez de passerelle, chaque ligne du roster serait à réécrire. Le roster nomme le moteur dans le langage du registre, la route est une affaire d’adaptateur : un seul endroit à changer, comme toujours derrière un port.


Fil rouge — la pièce posée aujourd’hui

Trois fichiers dans la zone « moteurs » du plan : .env.sample (la ligne que le plan réservait depuis le chapitre 1 se pose enfin), le roster version chapitre 15 et le port version chapitre 15. Les voisins travaillent déjà : la jauge d’hier lit le registre de la passerelle, le roster du chapitre 10 nomme les moteurs, les adaptateurs du chapitre 11 traduisent. La couture ne bouge pas d’un millimètre : la clé, le chargement de .env, le préfixe de route et le cumul des coûts sont du déterminisme pur. L’agent ne voit rien de tout cela, il reçoit un moteur et une mission, et ses enveloppes traversent comme avant. À l’usage : la commission de la passerelle, quelques pourcents à l’achat des crédits. Ce que la pièce économise : trois comptes fournisseurs sur quatre, la classe d’erreurs « le roster tourne chez moi mais pas chez toi », et un run mort quand un fournisseur tousse, le failover étant compris dans le prix. Demain, chapitre 16 : les frontier et les open weights se branchent, une ligne de YAML chacun, le compte existe déjà.


Travaux pratiques — la pièce du jour

Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique. Aujourd’hui, trois fichiers : le gabarit de la clé, le roster raccordé au registre, et le port qui route par la passerelle. Prérequis payant à annoncer : un compte OpenRouter avec quelques dollars de crédits. La gate agentique coûte environ un centime, et une lecture « à sec » est indiquée pour qui ne crédite pas de compte aujourd’hui.

Pièce — .env.sample

Le gabarit versionné de vos secrets : la forme, jamais la valeur. Il vit côté code, sous la protection du .gitignore posé au chapitre 1 (.env ignoré, .env.sample versionné). Copiez-le en .env et collez votre clé : c’est la seule fois du livre où une clé se manipule à la main.

# .env.sample — le gabarit des secrets de l'usine. Copiez en .env, remplissez.
# .env n'est JAMAIS commite (couvert par le .gitignore du ch. 1) ; ce gabarit, si.
# La cle de la passerelle : une cle, tous les moteurs du roster (ch. 15).
OPENROUTER_API_KEY=
# Module 6 : la cle de PROVISIONING restera cote hote, jamais dans un sandbox.

Pièce — adws/adw_config/factory.config.yaml

Cette version remplace celle du chapitre 13. Aucun agent ne change. L’évolution est le contrat déclaré en tête (les identifiants sont ceux du registre de la passerelle, la route est l’affaire du port) et les prix en commentaire, relevés le 31 août 2026, deviennent des fourchettes : un identifiant est un marché, pas une machine.

# factory.config.yaml — le roster de l'usine : un agent, un role, un modele.
# Les ADW nomment des agents, jamais des modeles : changer de moteur, c'est
# changer UNE ligne ici, aucun script.
#
# Contrat du chapitre 15 : les identifiants ci-dessous sont ceux du REGISTRE
# de la passerelle (openrouter.ai/models) — la meme chaine dans ce fichier,
# dans la jauge (model_stack.py) et sur la facture. La route (prefixe
# openrouter/) est l'affaire de l'adaptateur pi du port, jamais du roster.
# Une seule cle : OPENROUTER_API_KEY, chargee depuis .env par le port.
# Prix releves le 2026-08-31 — des fourchettes datees, pas des verites.
defaults:
  harness: pi                    # pi | claude — les deux adaptateurs du port (ch. 7)
  model: deepseek/deepseek-v4-flash-0731   # leger : ~0,03-0,08 $/M entree selon la route
  thinking: medium               # off | minimal | low | medium | high | xhigh | max
  tools: [read, bash, grep, find, ls]      # lecture + execution ; ecrire se merite
  # Interdit a tout agent qui ne le nomme pas lui-meme dans son `writes` :
  # un agent ne doit pas pouvoir editer la machinerie qui juge son travail.
  protected_files:
    - adws/adw_modules/
    - adws/adw_config/
    - adws/adw_*.py
    - PLAN.md
  data_dir: adws/adw_data        # le runtime : TOUJOURS ouvert, jamais commite

agents:
  - name: scout
    purpose: Reperer ou vivent les choses dans le repo ; ne rien changer.
    thinking: low                # il rapporte, il ne decide pas
    writes: []                   # lecture seule vis-a-vis du repo — pas muet :
                                 # son rapport atterrit sous data_dir
    # write : indispensable pour poser ce rapport — l'allowlist regle
    # l'interface, c'est writes: [] (verifie apres coup) qui protege le repo.
    tools: [read, bash, grep, find, ls, write]

  - name: planner
    purpose: Transformer une demande en plan que le builder implemente sans questions.
    model: anthropic/claude-opus-5        # frontier : ~5 $/M entree, ~25 $/M sortie
    thinking: high               # le plan porte tout le run — c'est ici qu'on paie
    writes:
      - specs/                   # le plan est la seule trace qu'il laisse au repo
    tools: [read, bash, grep, find, ls, write]

  - name: builder
    purpose: Implementer le plan, exactement ; declarer chaque fichier modifie.
    model: z-ai/glm-5.3                   # workhorse : ~1,40 $/M entree, ~4,40 $/M sortie
    thinking: high
    # pas de cle writes : libre dans le repo — mais protected_files tient toujours.
    tools: [read, bash, grep, find, ls, edit, write]

  - name: reviewer
    purpose: Confirmer que ce qui est construit est ce qui etait demande ; ne rien changer.
    model: google/gemini-3.7-flash        # intermediaire : ~0,38-0,75 $/M entree (paliers)
    thinking: high               # juger demande de reflechir, pas d'ecrire
    writes: []                   # un reviewer qui ne peut pas corriger ne peut pas
                                 # corriger en douce — regle, plus promesse

  - name: documenter
    purpose: Rediger apres coup ce qui a change et pourquoi ; n'ecrire que sous app_docs/.
    # modele et thinking herites des defauts (leger, medium) : rediger
    # d'apres un diff est un travail de rapport, pas de decision.
    writes:
      - app_docs/                # la memoire de l'usine — et rien d'autre
    tools: [read, bash, grep, find, ls, write]

Pièce — adws/adw_modules/harness.py

Cette version remplace celle du chapitre 11. Deux ajouts, tous deux côté déterministe : le port charge .env au chargement du module (les harnais ne le font pas d’eux-mêmes, et vos ADW non plus, un seul endroit suffit), et l’adaptateur pi préfixe la route de la passerelle. Tout le reste (contrat, adaptateurs, sessions, coûts) est inchangé : hello_factory.py, les ADW des chapitres 8 à 13 et la jauge d’hier tournent tels quels.

"""harness — le port de l'usine vers ses agents.

Une frontiere, deux adaptateurs. Aucun script de l'usine n'invoque `pi` ou
`claude` directement : tout passe par run(). Une HarnessRequest entre, un
HarnessResult sort — quel que soit le harnais derriere la porte.

Version chapitre 15 : le port charge .env (les harnais ne le font pas
d'eux-memes) et l'adaptateur pi route chaque modele par la passerelle —
le roster parle le langage du registre, l'adaptateur ajoute le prefixe.
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import uuid
from dataclasses import dataclass
from pathlib import Path

# Les sessions pi vivent dans adw_data/ — couvert par le .gitignore du ch. 1.
SESSION_DIR = Path("adws/adw_data/sessions")

# La route par defaut de l'usine : le fournisseur passerelle integre de pi.
# Une seule cle (OPENROUTER_API_KEY) sert tous les moteurs du roster.
# Passer par les API directes des fournisseurs : GATEWAY = "" — et a vous
# de fournir une cle par fournisseur dans l'environnement.
GATEWAY = "openrouter"

# Le dialecte Claude Code : outils avec majuscules, et pas d'outil ls ni find
# dedies — Bash et Glob les couvrent. L'adaptateur absorbe l'asymetrie.
CLAUDE_TOOLS = {"read": "Read", "bash": "Bash", "edit": "Edit", "write": "Write",
                "grep": "Grep", "find": "Glob", "ls": "Bash"}

# L'echelle de reflexion de pi, traduite en budget de tokens pour Claude Code
# (variable d'environnement MAX_THINKING_TOKENS).
THINKING_TOKENS = {"off": 0, "minimal": 1024, "low": 4096, "medium": 8192,
                   "high": 16384, "xhigh": 24576, "max": 32000}


def _load_env(path: str | Path = ".env") -> None:
    """Charge .env dans l'environnement du process — une fois, au chargement.

    Ni pi ni claude ne lisent .env d'eux-memes : sans ce chargement, la cle
    de la passerelle n'atteindrait jamais les agents. setdefault : une
    variable deja presente dans l'environnement reel gagne toujours — un
    export de session ou un secret de CI ne sont jamais ecrases.
    """
    env_file = Path(path)
    if not env_file.is_file():
        return
    for line in env_file.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        os.environ.setdefault(key.strip(), value.strip().strip("'\""))


_load_env()


class HarnessError(RuntimeError):
    """Le harnais n'a pas rendu de reponse exploitable."""


@dataclass(frozen=True)
class HarnessRequest:
    """Ce que l'usine a le droit de demander a un agent — rien de plus."""
    prompt: str
    session_id: str | None = None    # None = nouvelle session
    cwd: str = "."
    timeout: int = 600               # un agent muet ne bloque pas l'usine
    model: str | None = None         # l'id du REGISTRE, tel quel dans le roster
    thinking: str | None = None      # off..max — None = defaut du harnais
    tools: tuple[str, ...] = ()      # allowlist du roster — () = outils par defaut


@dataclass(frozen=True)
class HarnessResult:
    """Ce qu'un agent rend a l'usine — quel que soit le harnais."""
    text: str          # la derniere reponse de l'agent
    session_id: str    # de quoi poursuivre la MEME session
    cost_usd: float    # 0.0 si le harnais ne rapporte pas le cout
    returncode: int


def run(harness: str, request: HarnessRequest) -> HarnessResult:
    """L'unique porte d'entree vers les agents : choisit l'adaptateur, normalise."""
    try:
        adapter = ADAPTERS[harness]
    except KeyError:
        raise HarnessError(
            f"harnais inconnu {harness!r} — disponibles : {sorted(ADAPTERS)}"
        ) from None
    return adapter(request)


def _route(model: str) -> str:
    """L'id du registre devient une route pi : prefixe du fournisseur passerelle.

    Le roster parle le langage du registre (z-ai/glm-5.3) — la meme chaine
    que verifie la jauge du ch. 14. Le prefixe est un detail de dialecte :
    il vit ici, jamais dans le YAML ni dans vos scripts.
    """
    if not GATEWAY or model.startswith(GATEWAY + "/"):
        return model
    return f"{GATEWAY}/{model}"


def _spawn(cmd: list[str], request: HarnessRequest,
           extra_env: dict[str, str] | None = None,
           stdin_text: str | None = None) -> subprocess.CompletedProcess[str]:
    # Resoudre l'executable via le PATH : sous Windows, les harnais sont des
    # shims (pi.cmd, claude.cmd) que CreateProcess ne trouve pas par leur nom
    # court — shutil.which respecte PATHEXT et regle les deux mondes d'un coup.
    executable = shutil.which(cmd[0])
    if executable is None:
        raise HarnessError(f"{cmd[0]!r} introuvable dans le PATH — "
                           "le harnais est-il installe ?")
    environment = {**os.environ, **extra_env} if extra_env else None
    # Deux modes d'entree, jamais d'entre-deux :
    # - stdin_text=None : le prompt voyage dans argv, et stdin est ferme
    #   (DEVNULL) — un enfant qui herite de notre stdin peut attendre
    #   indefiniment une entree qui ne viendra jamais : echec silencieux,
    #   0 % CPU, aucune sortie.
    # - stdin_text : le prompt voyage par stdin, puis le tube est referme.
    #   Indispensable quand le harnais est un shim .cmd Windows : cmd.exe
    #   tronque un argument a la premiere nouvelle ligne, et les asks de
    #   l'usine (brief + mission + contrat) sont multi-lignes.
    io = ({"input": stdin_text} if stdin_text is not None
          else {"stdin": subprocess.DEVNULL})
    # Encodage explicite : les harnais emettent de l'UTF-8, mais text=True
    # seul decode avec la locale — cp1252 sous Windows, qui mutile tirets
    # et accents. Vaut pour la sortie ET pour le prompt ecrit sur stdin.
    try:
        return subprocess.run([executable, *cmd[1:]], **io,
                              capture_output=True, text=True,
                              encoding="utf-8", errors="replace",
                              env=environment,
                              timeout=request.timeout, cwd=request.cwd)
    except subprocess.TimeoutExpired:
        # Le timeout aussi sort par la porte normalisee : une seule exception.
        raise HarnessError(f"harnais muet apres {request.timeout} s : {cmd[0]}") from None


def _text_of(message: dict) -> str:
    """Concatene les blocs de texte d'un message pi."""
    return "".join(part.get("text", "") for part in message.get("content", []) or []
                   if isinstance(part, dict) and part.get("type") == "text")


def _run_pi(request: HarnessRequest) -> HarnessResult:
    # pi : c'est VOUS qui nommez la session. Meme id + meme dossier = meme
    # contexte — que la session existe deja ou non.
    session_id = request.session_id or str(uuid.uuid4())
    SESSION_DIR.mkdir(parents=True, exist_ok=True)
    cmd = ["pi", "-p", "--mode", "json",
           "--session-id", session_id, "--session-dir", str(SESSION_DIR)]
    # Le profil du roster, traduit dans le dialecte pi — et depuis le
    # chapitre 15, route par la passerelle : une cle, tous les moteurs.
    if request.model:
        cmd += ["--model", _route(request.model)]
    if request.thinking:
        cmd += ["--thinking", request.thinking]
    if request.tools:
        cmd += ["--tools", ",".join(request.tools)]
    cmd.append(request.prompt)
    proc = _spawn(cmd, request)

    # Le flux JSONL : seuls les message_end de l'assistant font foi. Le dernier
    # texte gagne (l'agent a pu parler entre deux outils) — mais chaque tour a
    # coute, donc le cout s'additionne au lieu de se remplacer.
    text, cost = "", 0.0
    for line in proc.stdout.splitlines():
        try:
            event = json.loads(line)
        except json.JSONDecodeError:
            continue
        if event.get("type") != "message_end":
            continue
        message = event.get("message", {})
        if message.get("role") != "assistant":
            continue
        text = _text_of(message) or text
        usage = message.get("usage", {}) or {}
        cost += (usage.get("cost", {}) or {}).get("total", 0.0) or 0.0

    if proc.returncode != 0 and not text:
        raise HarnessError(f"pi a rendu {proc.returncode} : {proc.stderr.strip()[-400:]}")
    return HarnessResult(text=text, session_id=session_id,
                         cost_usd=cost, returncode=proc.returncode)


def _run_claude(request: HarnessRequest) -> HarnessResult:
    # Claude Code : c'est LUI qui nomme la session. On la poursuit en rendant
    # son session_id via --resume.
    #
    # Regime par defaut : natif Anthropic — sa propre authentification, des
    # modeles Anthropic. Le pointer sur la passerelle est possible (trois
    # variables : ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, et
    # ANTHROPIC_API_KEY explicitement vide), mais la compatibilite n'est
    # garantie que sur les modeles Anthropic : l'adaptateur polyglotte de
    # l'usine reste pi, et ce choix-la appartient a votre environnement,
    # pas a cet adaptateur.
    cmd = ["claude", "-p", "--output-format", "json"]
    if request.model:
        # Le dialecte claude ignore le fournisseur : provider/id -> id.
        cmd += ["--model", request.model.split("/", 1)[-1]]
    if request.tools:
        # Traduire puis dedoublonner en gardant l'ordre : bash et ls donnent
        # tous deux Bash, inutile de le declarer deux fois.
        allowed = list(dict.fromkeys(
            CLAUDE_TOOLS[tool] for tool in request.tools if tool in CLAUDE_TOOLS))
        cmd += ["--allowedTools", ",".join(allowed)]
    if request.session_id:
        cmd += ["--resume", request.session_id]
    # L'echelle de reflexion devient un budget de tokens — l'asymetrie reste
    # dans l'adaptateur, le roster n'en sait rien.
    extra_env = ({"MAX_THINKING_TOKENS": str(THINKING_TOKENS[request.thinking])}
                 if request.thinking in THINKING_TOKENS else None)
    # Le prompt part par stdin, PAS dans argv : c'est un mode documente de
    # claude -p, et le seul qui survive aux shims .cmd de Windows.
    proc = _spawn(cmd, request, extra_env, stdin_text=request.prompt)

    if proc.returncode != 0:
        # stderr d'abord, stdout sinon : en --output-format json, claude
        # ecrit souvent son erreur en JSON sur stdout, stderr vide.
        evidence = (proc.stderr.strip() or proc.stdout.strip())[-400:]
        raise HarnessError(f"claude a rendu {proc.returncode} : {evidence}")
    try:
        payload = json.loads(proc.stdout)
    except json.JSONDecodeError:
        raise HarnessError("claude n'a pas rendu l'objet JSON attendu "
                           "(--output-format json)") from None
    return HarnessResult(text=str(payload.get("result", "")),
                         session_id=str(payload.get("session_id", "")),
                         cost_usd=float(payload.get("total_cost_usd") or 0.0),
                         returncode=proc.returncode)


# Le registre des adaptateurs. Un harnais de plus = une fonction + une ligne.
ADAPTERS = {"pi": _run_pi, "claude": _run_claude}

La gate du TP

Deux commandes, à la racine de plume-factory, une par ligne. La première ne coûte rien, la seconde est le premier tour de l’usine entièrement routé et facturé par la passerelle.

# 1) zero token : le roster se charge et la jauge confirme chaque moteur au registre
uv run adws/model_stack.py

# 2) ~1 centime : un vrai tour par la passerelle, sur le leger du roster
uv run adws/adw_scout.py "Quelle commande lance les tests de apps/plume ?"

Attendu : la jauge rend jauge : OK avec les identifiants du roster sans préfixe, l’invariant du jour, puis le scout rend son enveloppe de findings et sa ligne session … — cout …, pour ~1 centime et 1 à 2 minutes. La requête apparaît dans le tableau d’activité de votre compte passerelle, modèle et coût à l’appui. Lecture à sec si vous ne créditez pas de compte aujourd’hui : la commande 1 suffit, elle prouve roster et registre d’accord, et la commande 2 vous attend au chapitre 16. Quand la gate passe, commitez les trois fichiers.


Quiz — teste tes connaissances
Model stack 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.