Typer une API ML de bout en bout : FastAPI, pydantic et un client TypeScript généré

Le contrat d'API écrit une seule fois en Python, publié en OpenAPI par FastAPI, et le TypeScript du frontend généré à partir de là. Un contrôle en CI vérifie que rien n'a dérivé. Pas de client écrit à la main.

La panne classique n'a rien de spectaculaire. Un champ est renommé côté serveur, le frontend lit toujours l'ancien nom, undefined arrive dans un graphique, et le graphique affiche un état vide qui a l'air normal. Personne ne s'en rend compte pendant une semaine.

La correction est structurelle : arrêter de maintenir le contrat à deux endroits. Je le définis une fois en Python, FastAPI le publie en OpenAPI, et le TypeScript est généré à partir de là. Le seul composant qui doit être juste, c'est le générateur.

La chaîne

Les types circulent des modèles pydantic vers le TypeScript généré, via OpenAPI ; un contrôle CI ferme la bouclemodèle pydanticsource uniqueFastAPIvalide + documenteopenapi.jsonartefact généréapi.d.tstypes générésla CI régénère et échoue si la sortie commitée diffère
Figure 1. L'autorité va dans un seul sens. Rien en aval du modèle pydantic n'est écrit à la main, donc rien en aval ne peut dériver. Le retour en pointillés, c'est le contrôle CI qui permet de le vérifier.

Les pièces

  • pydantic v2 : modèles de requête et de réponse, avec validation
  • FastAPI : les sert, et publie /openapi.json à partir des mêmes objets
  • openapi-typescript : transforme ce document en .d.ts
  • Un contrôle CI qui régénère et échoue à la moindre différence

C'est le dernier point qui rend le reste fiable. Sans lui, le fichier généré devient un instantané que quelqu'un finira par modifier à la main.

Le serveur de modèle

from typing import Annotated, Literal

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI(title="inference", version="class="tok-num">1.0.class="tok-num">0")

class EmbedRequest(BaseModel):
class=class="tok-str">"tok-com">    # Les contraintes vont dans le schéma : elles sont validées à l&#class="tok-num">39;exécution
class=class="tok-str">"tok-com">    # ET exportées dans OpenAPI, donc le frontend voit les mêmes bornes que
class=class="tok-str">"tok-com">    # celles imposées par le serveur.
    texts: Annotated[list[str], Field(min_length=class="tok-num">1, max_length=class="tok-num">64)]
    normalize: bool = True
    model: Literal["e5-large", "bge-m3"] = "e5-large"

class EmbedResponse(BaseModel):
    vectors: list[list[float]]
    dim: int
    model: str
class=class="tok-str">"tok-com">    # Sérialisé en nombre JSON ; le déclarer garde le type TS exact.
    elapsed_ms: float

@app.post("/embed", response_model=EmbedResponse)
def embed(req: EmbedRequest) -> EmbedResponse:
    ...

Trois habitudes qui se rentabilisent vite.

Literal pour les ensembles fermés. Il devient une union TypeScript : un nom de modèle invalide est une erreur de compilation côté frontend, bien avant le 422 à l'exécution.

Les contraintes dans Field. max_length=64 est appliqué par le serveur, documenté dans OpenAPI et visible par quiconque écrit le client. La même règle à trois endroits, écrite une seule fois.

La version de l'API dans le chemin. /v1/embed permet de générer deux clients pendant une migration. Une version qui n'existe que dans les métadonnées ne sert à rien le jour où il faut faire cohabiter deux versions.

Générer le client

"tok-com"># Régénérer ne coûte rien ; garder la sortie dans git rend la dérive relisible.
.PHONY: types check-types

types:
    python -c "import json, app.main as m; print(json.dumps(m.app.openapi(), indent=2, sort_keys=True))" > openapi.json
    npx openapi-typescript openapi.json -o frontend/src/api.d.ts
"tok-com">
# CI : régénérer puis comparer. Échoue si quelqu'un a modifié le serveur
"tok-com"># sans régénérer, ou a retouché le fichier généré à la main.
check-types: types
    git diff --exit-code openapi.json frontend/src/api.d.ts

sort_keys=True compte plus qu'il n'y paraît. Sans lui, un simple changement d'ordre des clés produit des différences qui ne changent rien au sens, le contrôle sonne pour rien, et l'équipe apprend à le contourner.

Je génère depuis app.openapi() directement, sans lancer de serveur : pas de port, pas de démarrage, rien d'aléatoire.

Côté frontend

import type { components } from "./api";

type EmbedRequest = components["schemas"]["EmbedRequest"];
type EmbedResponse = components["schemas"]["EmbedResponse"];

class=class="tok-str">"tok-com">// Une seule fonction typée, utilisée partout. Les types sont importés :
class=class="tok-str">"tok-com">// impossible de se tromper sur les paramètres.
async function embed(req: EmbedRequest): Promise<EmbedResponse> {
  const r = await fetch("/api/embed", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(req),
  });
  if (!r.ok) throw new Error(class="tok-str">`embed failed: ${r.status}`);
  return r.json();
}

Ce code ne valide pas la réponse. Les types affirment, à la compilation, que le serveur respecte son contrat, et rien ne le vérifie à l'exécution. Pour une API interne protégée par son propre contrôle CI, c'est en général un bon compromis. Pour tout ce qui traverse une frontière de confiance, je valide aussi la réponse à l'exécution : des types générés décrivent tranquillement une réponse qui n'arrivera jamais sous cette forme.

Là où ça casse

Any se propage. Un seul dict[str, Any] dans un modèle pydantic devient Record<string, unknown>, et la garantie s'arrête là. Si un champ est vraiment polymorphe, je le modélise comme une union discriminée.

Les types NumPy ne sont pas des types JSON. np.float32 n'est pas un float, et pydantic le refusera. Je convertis à la frontière, avec .tolist() ou .item(), dans la construction de la réponse. Caché dans un hook de sérialisation, le problème devient invisible.

Les grands tableaux se prêtent mal au JSON. Une réponse d'embeddings de 64 × 1024 flottants pèse plusieurs mégaoctets de texte décimal. Dès que la taille compte, il faut passer à un encodage binaire (Arrow, npy ou float32 en base64), le schéma décrivant toujours l'enveloppe.

Optionnel et nullable sont deux choses différentes. str | None et « le champ peut être absent » génèrent des TypeScript différents. Le comportement par défaut est rarement celui qu'on voulait, donc je choisis explicitement.

Tester le contrat, en plus de le générer

Le contrôle CI prouve que les types générés correspondent au serveur. Il ne dit rien de la correspondance entre le serveur et ce qu'il renvoie vraiment, et ce deuxième point échoue à sa manière.

Tester le schéma, en passant par la route. Un modèle de réponse qui déclare dim: int alors que le handler renvoie un entier NumPy passe tous les tests unitaires qui appellent la fonction directement, puis échoue à la sérialisation. Je fais passer la route par le client de test pour que pydantic s'exécute pour de vrai :

from fastapi.testclient import TestClient

client = TestClient(app)

def test_embed_response_matches_schema():
    r = client.post("/embed", json={"texts": ["hello"], "model": "e5-large"})
    assert r.status_code == class="tok-num">200
class=class="tok-str">"tok-com">    # Revalider avec le modèle déclaré EST l&#class="tok-num">39;assertion : si le handler renvoie
class=class="tok-str">"tok-com">    # une forme que le schéma ne décrit pas, ceci lève une exception.
    EmbedResponse.model_validate(r.json())

def test_constraints_are_enforced():
class=class="tok-str">"tok-com">    # class="tok-num">65 éléments contre max_length=class="tok-num">64. La borne n&#class="tok-num">39;est écrite qu&#class="tok-num">39;à un seul
class=class="tok-str">"tok-com">    # endroit : ce test protège le contrat lui-même.
    r = client.post("/embed", json={"texts": ["x"] * class="tok-num">65})
    assert r.status_code == class="tok-num">422

Un changement de schéma est un changement d'API. Ajouter un champ optionnel ne casse rien. Rendre obligatoire un champ optionnel, restreindre un Literal ou renommer quoi que ce soit, si. La décision se prend en lisant la différence générée en CI, et c'est une deuxième raison de commiter les fichiers générés.

Garder le document OpenAPI de chaque version. Avec le openapi.json précédent sous la main, on compare deux versions et on répond mécaniquement à « a-t-on cassé un client ? », sans compter sur la mémoire de qui que ce soit.

L'intuition mathématique : une source, zéro copie

Pourquoi un contrat écrit à un seul endroit tient-il mieux que deux contrats soigneusement maintenus ? Parce que le nombre de façons de diverger grandit avec le nombre de copies.

Avec k copies du même contrat (le modèle pydantic, le client TypeScript, la documentation…), il y a k(k − 1)/2 paires qui doivent rester d'accord : 1 paire pour 2 copies, 3 pour 3, 6 pour 4. Et chaque changement doit être reporté à la main dans les k − 1 autres copies. Si chaque report est juste avec une probabilité p, la probabilité qu'aucune dérive n'apparaisse après m changements vaut :

P(aucune dérive) = p^(m(k − 1))

Animation. Chaque copie tenue à la main ajoute des paires à garder alignées. 100 reports à la main (50 changements × 2 copies) suffisent pour qu'une dérive passe presque à coup sûr. Avec une source unique et un contrôle CI, il n'y a plus rien à reporter.

Avec des reports justes 98 fois sur 100, 50 changements et 3 copies : 0,98^100 ≈ 13 %. Dans 87 % des cas, au moins une dérive s'est glissée quelque part. Même un travail soigné perd à ce jeu, parce que les petites probabilités d'erreur s'accumulent. Générer le client depuis une source unique ramène k − 1 à zéro, et p^0 = 1, à condition qu'un contrôle CI vérifie que le fichier généré correspond bien à la source. Sans ce contrôle, le fichier généré redevient une copie comme les autres.

Pourquoi ça tient

Le système de types aide, mais ce qui fait vraiment marcher l'ensemble, c'est qu'il existe un seul endroit où le contrat est écrit, et un contrôle qui échoue dès que quelque chose en aval n'est pas d'accord avec lui.

On obtiendrait la même garantie avec protobuf, ou avec un schéma écrit à la main et un générateur de code. Dans tous les cas, c'est le contrôle CI qui apporte la garantie. Un fichier généré que personne ne vérifie finit par vivre sa vie comme un fichier écrit à la main.