Ingénierie · 28 septembre 2026

Garde-fous Vercel AI SDK les correspondances coupées

Les exemples de middleware du AI SDK ne compilaient pas et wrapStream restait vide. Le signalement, ce qui a fusionné le 22 septembre 2026, et la règle.

Cinq exemples, aucun ne compilait

Le AI SDK documente le middleware de modèle de langage sur une seule page, et cette page portait cinq exemples complets. Copiés dans un projet qui tourne avec ai@7.0.107 et @ai-sdk/provider@4.0.17, tous échouaient de la même façon :

error TS2741: Property 'specificationVersion' is missing in type
'{ wrapGenerate: ... }' but required in type 'LanguageModelV4Middleware'.

specificationVersion: 'v4' est un champ obligatoire en lecture seule du type middleware, et la page ne le mentionnait nulle part — ni dans le premier exemple, ni dans une note, ni en passant. Rien de subtil ne clochait ici. Tous les exemples de la page échouaient sur la même propriété manquante, quel que soit ce que chacun démontrait. Le lecteur qui suivait la page obtenait cette erreur dès son premier middleware, et rien sur la page ne l’expliquait.

La moitié laissée en commentaire

Le second problème était le plus grave, et la page en convenait. L’exemple de garde-fou implémentait wrapGenerate, exécutait un .replace() sur le texte terminé, et laissait la moitié streaming en commentaire, reproduit ici exactement tel qu’il a été relevé :

here you would implement the guardrail logic for streaming // Note: streaming guardrails are difficult to implement, because you do not know the full content of the stream until it's finished.

Cette note est vraie, et elle énonce toute la difficulté sans détour. Ce qu’elle ne dit pas, c’est ce qui arrive au lecteur qui y voit une invitation. Le geste évident consiste à sortir le .replace() de wrapGenerate pour le déposer dans une transformation sur les morceaux du flux. Le résultat ressemble à un garde-fou qui fonctionne, survit à un essai à la main sur une requête courte, et se trompe de deux façons distinctes.

Première façon : la correspondance n’est jamais là où on la cherche

Un modèle émet le texte par deltas découpés sur les frontières de tokens, pas sur le sens. Toute valeur assez longue pour mériter un masquage s’étale en général sur plusieurs d’entre eux.

delta 1: "...your card is 4111 11"
delta 2: "11 1111 1111, charge it."

Un .replace() exécuté sur le delta 1 ne trouve aucun numéro de carte. Exécuté sur le delta 2, il n’en trouve aucun non plus. Les deux deltas partent vers le client, le navigateur les recolle, et le numéro est à l’écran. Aux tailles de delta que les fournisseurs envoient réellement, ce n’est pas un cas limite : pour la plupart des valeurs qui méritent un filtre, c’est le cas courant.

Deuxième façon : le secret à moitié masqué

La réparation vers laquelle on se tourne consiste à garder une fenêtre de texte récent, à l’analyser, et à relâcher ce qui en sort par l’arrière. Celle-là passe la revue de code, et elle échoue plus mal que la première, parce qu’elle produit une sortie qui a l’air traitée.

Certains motifs cessent de grandir ; beaucoup non. Un motif pour un secret de longueur variable correspond dès que le texte est assez long pour qualifier, et il continue de correspondre à mesure que d’autres caractères arrivent. Si le point de règlement tombe à l’intérieur d’une valeur encore en train de grandir — et avec une fenêtre fixe, c’est souvent le cas —, le filtre masque la partie qu’il retient et diffuse le reste en clair :

out: "your key is <REDACTED>uvwxyz123456"

La moitié du secret est dans la transcription, à côté d’un marqueur qui annonce au lecteur qu’elle a été retirée. Une fuite visible est un bogue. Une fuite qui porte l’étiquette du masquage est un bogue plus une fausse assurance, et c’est la seconde partie qui coûte cher : personne ne revient vérifier.

La règle

Ne jamais agir sur une correspondance qui peut encore grandir, et ne jamais émettre de texte que l’on n’a pas analysé dans sa forme définitive.

Les deux moitiés comptent. La première exclut de masquer une valeur avant de savoir où elle se termine. La seconde exclut de relâcher du texte que l’on n’a jamais vu qu’en morceaux. En pratique, cela veut dire retenir depuis le début de toute correspondance qui dépasse le point de règlement, plutôt qu’à partir d’un décalage fixe en arrière. Un décalage fixe est une supposition sur la longueur des secrets. Le début de la correspondance est un fait sur le texte que l’on a devant soi.

La propriété, écrite comme un test

La règle est une consigne de conception. Voici sa forme vérifiable par une machine :

Pour n’importe quelle entrée et n’importe quelle découpe, le résultat diffusé est identique octet pour octet au filtrage de la chaîne entière.

Rejouez la même entrée à chaque taille de delta — un caractère, deux, trois, jusqu’à la chaîne entière — et comparez chaque exécution au résultat en une seule passe. Les deux défaillances ci-dessus deviennent une différence, et personne n’a à raisonner sur la frontière qui a joué de malchance. C’est court, et cela se moque de la façon dont le filtre est construit. La classe de défauts qu’il attrape porte un nom et un texte plus long : Données coupées entre deux chunks.

Ce qui a été fusionné

Le signalement est vercel/ai#21209, « docs: guardrails example leaves wrapStream unimplemented », déposé le 20 septembre 2026. Il a été clos le 22 septembre, après neuf commentaires. Le 28 septembre 2026, le dépôt comptait 27 011 étoiles. Trois pull requests ont été fusionnées à seize secondes d’intervalle :

Soit +335/-35 sur trois branches. L’issue porte les étiquettes merged-main, merged-v5.0, merged-v6.0 et documentation-fix, entre autres. Au 28 septembre 2026, la page middleware en ligne porte specificationVersion: 'v4'.

L’exemple wrapStream qui a été retenu ne dépend d’aucune bibliothèque. Il garde un tampon par identifiant de bloc de texte — un flux peut porter plusieurs blocs de texte à la fois, et c’est exactement à cela que sert l’id porté par chaque morceau, car un tampon unique partagé attribue le texte au mauvais bloc. Il calcule une coupe, parcourt les correspondances du tampon, et quand une correspondance dépasse la coupe, il retient depuis m.index plutôt que depuis la coupe. Il ne coupe jamais à l’intérieur d’une paire de substitution, si bien qu’un emoji n’arrive pas en deux moitiés cassées. Lu d’un bloc, c’est la règle ci-dessus exprimée en code au lieu d’être décrite dans un commentaire.

La déclaration d’intérêt venait en premier

Un détail compte plus que le diff. L’issue s’ouvrait sur ceci, avant même que le problème soit décrit : « Déclaration d’intérêt : je maintiens une bibliothèque MIT qui fait du masquage en flux, j’ai donc un intérêt ici. La suggestion ci-dessous est volontairement autonome — elle n’en dépend pas et vous pouvez la déposer telle quelle dans la documentation. »

La bibliothèque est llm-stream-guardrails, et elle est arrivée sur npm le 21 septembre 2026 — le lendemain du dépôt de l’issue. Le signalement est venu en premier, et ce qu’il proposait était un correctif qu’un mainteneur pouvait prendre sans rien prendre d’autre avec lui. C’est le seul arrangement sous lequel quelqu’un qui maintient une solution concurrente devrait déposer un rapport chez un projet de cette taille : déclarer l’intérêt en tête, puis rendre la suggestion assez bonne pour survivre à cette déclaration.

Si vous en écrivez un

Prenez la règle et l’invariant ; rien d’autre sur cette page n’est nécessaire. Les deux fonctionnent sans aucune dépendance, et l’exemple qui figure maintenant dans la documentation du AI SDK en est une implémentation complète. D’autres bibliothèques traitent correctement les frontières de morceaux avec une anticipation bornée, et elles vont très bien — la question n’est pas quel paquet figure sur la ligne d’import, elle est de savoir si la sortie survit à un rejeu à toutes les tailles de morceaux. Si elle n’y survit pas, le paquet n’y changera rien.

Les limites méritent d’être dites franchement, car un filtre en flux inspire plus de confiance qu’il n’en a gagné. Il reconnaît des formats, pas du sens : il peut attraper un numéro de carte et n’attrapera pas l’histoire médicale d’un client rédigée en prose. Il s’exécute après que le modèle a décidé de ce qu’il dit, c’est donc une dernière ligne plutôt qu’une première. Et aucun filtre d’aucune bibliothèque ne rend un système conforme à quoi que ce soit ; la conformité est une propriété du système et de ceux qui l’exploitent. Ce que la règle vous achète est plus étroit, et vaut la peine en soi : une sortie identique, qu’elle soit arrivée en un seul morceau ou en quatre cents.

FAQ

Questions que posent les ingénieurs

Comment corriger « error TS2741: Property 'specificationVersion' is missing » ?

Ajoutez specificationVersion: 'v4' à l’objet middleware. C’est un champ obligatoire en lecture seule de LanguageModelV4Middleware : un objet qui ne porte que wrapGenerate ou wrapStream ne passe pas le typage. La page middleware du AI SDK ne mentionnait jamais ce champ, et c’est pourquoi ses cinq exemples échouaient avec ai@7.0.107 et @ai-sdk/provider@4.0.17. La page a été corrigée le 22 septembre 2026 et affiche désormais le champ. Si vous avez collé un exemple avant cette date, cette seule ligne est tout le correctif.

\n
Pourquoi les exemples de middleware du AI SDK ne compilaient-ils pas ?

Tous les exemples de la page échouaient sur la même propriété manquante, quel que soit ce que chacun démontrait : specificationVersion est un champ obligatoire du type middleware, et la page ne le mentionnait nulle part — ni dans le premier exemple, ni dans une note. L’échec tenait à la même ligne unique manquante dans chaque objet. Trois pull requests fusionnées le 22 septembre 2026 ont corrigé la page sur la branche principale et sur les deux branches de version.

\n
Puis-je déplacer mon .replace() de wrapGenerate vers wrapStream ?

Vous le pouvez, et il ne fera pas ce qu’il semble faire. Un .replace() appliqué à chaque delta ne voit jamais que ce delta, et une valeur qui mérite un masquage s’étale d’ordinaire sur plusieurs. « 4111 11 » arrive, puis « 11 1111 1111 » : aucun des deux morceaux ne correspond à un motif de carte, les deux partent vers le client, et le navigateur les recolle à l’écran. Ce geste qui ressemble à une petite refonte est précisément celui qui produit un filtre qui passe l’essai à la main et fuit en production.

\n
Mon masquage en flux ne trouve jamais rien. Qu’est-ce qui m’échappe ?

Le texte contre lequel vous cherchez n’a jamais été une unité. Les fournisseurs découpent les deltas sur les frontières de tokens, pas sur le sens : un numéro de carte, une adresse e-mail ou une clé d’API chevauchent couramment deux morceaux ou plus. Chercher delta par delta ne trouve ni l’une ni l’autre moitié. Le remède n’est pas un meilleur motif, c’est un état : gardez un tampon d’un delta à l’autre et retenez tout texte dans lequel une correspondance peut encore grandir, au lieu d’analyser chaque delta comme s’il était un document terminé.

\n
Puis-je simplement tout mettre en tampon et filtrer à la fin ?

C’est correct, et c’est la chose la plus simple qui satisfasse l’invariant. C’est aussi renoncer à la seule raison de diffuser : le délai jusqu’au premier jeton devient le délai de génération complète, et la mémoire croît avec la longueur du bloc. Le compromis convient aux réponses courtes et devient pénible sur les longues. Retenir depuis le début d’une correspondance encore ouverte garde le flux qui coule et donne un résultat identique, au prix d’un peu plus de code dans la transformation.

\n
Quelle taille de retenue choisir : 32, 64 ou 128 caractères ?

Aucune, prise seule. Tout N fixe peut être mis en défaut par une correspondance plus longue que N, et quand cela arrive, il échoue en silence, puisque la sortie porte toujours un marqueur de masquage. La quantité retenue doit venir du texte, pas d’une constante : retenez depuis l’indice où commence une correspondance encore ouverte, et relâchez tout ce qui précède. Si vous devez plafonner le tampon, décidez à l’avance de ce qui se passe au plafond, et que la réponse soit « refuser », pas « relâcher et espérer ».

\n
Pourquoi ma sortie affiche-t-elle un marqueur de masquage suivi d’une demi-clé d’API ?

Parce que votre point de règlement est tombé à l’intérieur d’une valeur encore en train de grandir. Les secrets de longueur variable correspondent dès qu’ils sont assez longs pour qualifier, si bien qu’un filtre qui retient une fenêtre fixe masque les caractères qu’il détient et diffuse le reste intact. La sortie se lit <REDACTED>uvwxyz123456 : la moitié de la clé, à côté d’une étiquette qui dit qu’elle a été retirée. C’est pire que pas de filtre du tout, car une fuite visible est signalée et une fuite étiquetée ne l’est pas.

\n
Pourquoi un tampon par identifiant de texte plutôt qu’un tampon partagé ?

Parce qu’un même flux peut porter plusieurs blocs de texte à la fois, et c’est exactement à cela que sert l’id de chaque morceau. Un tampon unique les mélange : le texte d’un bloc est analysé, retenu et relâché au titre d’un autre — masquages erronés d’un côté, ordre erroné de l’autre. L’exemple fusionné dans la documentation du AI SDK indexe son tampon par identifiant de texte précisément pour cette raison.

\n
Comment tester un garde-fou en streaming ?

Une seule assertion, exécutée sur chaque cas et à chaque taille de morceau : la sortie diffusée concaténée doit être identique octet pour octet au filtrage de la chaîne entière. Rejouez la même entrée à un caractère par delta, puis deux, puis trois, jusqu’à la chaîne entière, et comparez chaque exécution au résultat en une seule passe. Les correspondances coupées et les valeurs à moitié masquées apparaissent toutes deux comme une différence, et personne n’a à deviner quelle frontière est dangereuse. C’est un court bout de code de test.

\n
Quelles branches du AI SDK ont reçu l’exemple wrapStream corrigé ?

Trois branches, le même jour. La pull request #21210 a atterri sur main, la #21211 sur release-v6.0 et la #21212 sur release-v5.0, toutes fusionnées le 22 septembre 2026 à seize secondes d’intervalle — 1 fichier chacune pour les deux premières, 2 fichiers sur la branche v5, +335/-35 au total. Au 28 septembre 2026, la documentation middleware en ligne porte specificationVersion: 'v4'.

\n
Faut-il une bibliothèque pour masquer un flux ?

Non. L’exemple qui figure maintenant dans la documentation du AI SDK est autonome et ne dépend de rien, et la règle qu’il implémente est assez courte pour être écrite soi-même : ne jamais agir sur une correspondance qui peut encore grandir, ne jamais émettre de texte que l’on n’a pas analysé dans sa forme définitive. Plusieurs bibliothèques traitent correctement les frontières de morceaux avec une anticipation bornée. Ce qui compte n’est pas le nom sur la ligne d’import, mais de savoir si la sortie survit à un rejeu à toutes les tailles de morceaux.

\n
Est-ce un problème propre au Vercel AI SDK ?

Non. Le SDK n’a livré aucun filtre défectueux : il a livré une lacune de documentation, la moitié streaming d’un garde-fou laissée en commentaire. La défaillance qu’elle invite a été livrée indépendamment par des projets sans lien entre eux, dans plus d’un langage, ce qui en fait une classe de défauts plutôt que l’erreur d’une équipe. Un autre texte de ce site couvre ces cas, le nom donné à la classe et la citation qui lui correspond.

\n
Mettre en tampon et couper le texte casse-t-il les emojis ?

Oui, si vous coupez naïvement. Un caractère hors du plan multilingue de base est stocké sur deux unités de code, et trancher entre les deux émet une demi-lettre qu’aucun consommateur en aval ne peut réparer. L’exemple fusionné vérifie ce cas et déplace sa coupe plutôt que de séparer une paire de substitution. Le même soin vaut pour tout ce que vous construisez : le point de règlement est une position dans une chaîne, et toute position dans une chaîne n’est pas un endroit valide où s’arrêter.

\n
Pourquoi l’issue commençait-elle par une déclaration d’intérêt ?

Parce qu’elle était vraie, et qu’elle change la façon de lire la suggestion. Le mainteneur d’une bibliothèque MIT de masquage en flux signalait une lacune que sa propre bibliothèque comble. La suggestion a été écrite pour être autonome précisément pour que cet intérêt n’ait pas d’importance : aucune dépendance, rien à installer, à coller dans la documentation. La déclarer en premier est ce qui rend le reste du signalement utilisable par des gens qui ne vous doivent rien.