IA & agents

Un modèle dans une application .NET et Angular

Appeler un modèle depuis ASP.NET Core, streaming vers Angular, clés et coûts.

Vérifié en septembre 2026 · .NET 10, Angular 22.1 · environ 16 min

Brancher un modèle de langage dans une application web, c'est ajouter un appel HTTP sortant qui a trois propriétés inhabituelles : il est payant, il est lent, et il est facturé au volume. Chacune dicte une partie de l'architecture. Payant, il passe par le serveur, seul à détenir la clé. Lent, sa réponse est relayée morceau par morceau jusqu'au navigateur. Facturé au jeton, il doit s'arrêter quand l'utilisateur s'en va et rester dans un budget. Le cours suit une question d'un bout à l'autre — composant Angular, endpoint ASP.NET Core 10, abstraction IChatClient, SDK du fournisseur — puis la teste sans appeler aucun modèle. Le site de révision n'a pas de backend : c'est l'application d'exemple du cours qui en a un.

La clé reste sur le serveur

Une clé d'API de fournisseur est un moyen de paiement : qui la détient consomme sur le compte. Or tout ce qui est livré au navigateur est public — le JavaScript, les fichiers d'environnement, les en-têtes visibles dans l'onglet Réseau. Aucune obfuscation n'y change rien : pour que le navigateur envoie la clé, il faut qu'il la possède.

import { environment } from '../environments/environment';

// La cle est injectee au build, par exemple par la CI dans environment.prod.ts.
// Quelle que soit la facon dont elle arrive la, elle finit en clair dans le
// JavaScript servi : l'onglet Reseau des outils de developpement la montre
// dans l'en-tete de chaque requete, et quiconque la recopie consomme a vos
// frais, sans autre limite que celle du compte.
export async function demander(question: string): Promise<string> {
  const reponse = await fetch('https://api.fournisseur.example/v1/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${environment.cleIa}`,
    },
    body: JSON.stringify({ messages: [{ role: 'user', content: question }] }),
  });
  return reponse.text();
}

Les fournisseurs en tirent la conséquence : le SDK TypeScript d'Anthropic, par exemple, refuse de s'exécuter dans un navigateur tant qu'on ne lui passe pas une option nommée dangerouslyAllowBrowser. La réponse est d'inverser le trajet. Le navigateur appelle votre API, avec l'authentification de votre application ; le serveur appelle le fournisseur avec une clé lue dans sa configuration.

using Anthropic;
using Microsoft.Extensions.AI;
using OpenAI;

var builder = WebApplication.CreateBuilder(args);

// La cle vient de la configuration, jamais du code : en developpement, des
// secrets utilisateur (dotnet user-secrets set "Ia:Cle" "..."), stockes hors
// du depot ; en production, d'une variable d'environnement Ia__Cle ou d'un
// coffre de secrets. Elle ne quitte jamais ce processus.
var ia = builder.Configuration.GetSection("Ia");
var cle = ia["Cle"] ?? throw new InvalidOperationException("Ia:Cle est absente de la configuration.");
var modele = ia["Modele"] ?? throw new InvalidOperationException("Ia:Modele est absent de la configuration.");

// Le SDK du fournisseur est derriere l'abstraction. Changer de fournisseur
// change ce bloc et rien d'autre dans l'application.
IChatClient client = ia["Fournisseur"] switch
{
    // Paquets OpenAI et Microsoft.Extensions.AI.OpenAI.
    "openai" => new OpenAIClient(cle).GetChatClient(modele).AsIChatClient(),
    // Paquet Anthropic, le SDK officiel, qui implemente IChatClient lui-meme.
    "anthropic" => new AnthropicClient { ApiKey = cle }.AsIChatClient(modele),
    var autre => throw new InvalidOperationException($"Fournisseur inconnu : {autre}"),
};

// AddChatClient enregistre un singleton et rend un ChatClientBuilder : chaque
// Use* ajoute un IChatClient delegant autour du client reel, a la maniere d'un
// middleware. UseLogging journalise chaque appel ; le contenu des messages
// n'est ecrit qu'au niveau Trace, a ne jamais activer en production.
builder.Services.AddChatClient(client)
    .UseLogging();

Ce déplacement a un prix qu'il faut assumer : l'endpoint devient un relais qui dépense de l'argent pour quiconque l'appelle. Il lui faut ce qu'exige toute ressource coûteuse : une authentification, que les exemples laissent de côté pour rester courts, puis une limite par utilisateur et une question de taille bornée, montrées plus bas. En échange, le serveur décide du contenu : le prompt système, le modèle et les options restent chez lui, et le navigateur n'envoie que la question.

IChatClient : une abstraction, des fournisseurs

Microsoft.Extensions.AI fait pour les modèles ce qu'ILogger a fait pour la journalisation : une interface commune, implémentée par les SDK, consommée par un code qui ignore le fournisseur. Le paquet Microsoft.Extensions.AI.Abstractions déclare les types d'échange, et c'est lui que référence une bibliothèque qui implémente un client. Microsoft.Extensions.AI y ajoute l'outillage — ChatClientBuilder, AddChatClient, journalisation, télémétrie, cache, invocation d'outils —, et c'est lui que référence une application. Le contrat tient en trois méthodes.

using Microsoft.Extensions.AI;

namespace Illustration;

// Le contrat, tel que le declare Microsoft.Extensions.AI.Abstractions.
public interface IChatClient : IDisposable
{
    // La reponse entiere, une fois la generation terminee.
    Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default);

    // Une mise a jour par morceau recu, au fil de la generation.
    IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default);

    // Acces aux objets sous-jacents, dont le client natif du SDK.
    object? GetService(Type serviceType, object? serviceKey = null);
}

Les deux premières reçoivent une liste de messages. Avec un service sans état, le cas courant, c'est la conversation entière, car le modèle ne voit que ce qu'on lui envoie à chaque appel (cours Prompts) ; ChatOptions.ConversationId sert aux services qui gardent l'historique de leur côté. ChatOptions porte les réglages communs — MaxOutputTokens, Temperature, ModelId, Tools — et chaque adaptateur les traduit dans le dialecte de son fournisseur. Mesuré contre un faux serveur local, MaxOutputTokens part en max_tokens chez Anthropic et en max_completion_tokens chez OpenAI.

Les deux fournisseurs de l'exemple précédent illustrent les deux voies possibles, telles qu'elles existent en septembre 2026. OpenAI passe par un adaptateur publié par Microsoft, Microsoft.Extensions.AI.OpenAI, et sa méthode AsIChatClient() ; le SDK officiel d'Anthropic, paquet Anthropic, implémente l'interface lui-même. D'autres SDK, comme OllamaSharp pour un modèle local, font de même. Ce qui n'est pas commun ne disparaît pas pour autant : une option propre à un fournisseur passe par ChatOptions.AdditionalProperties ou par RawRepresentationFactory, qui fabrique l'objet d'options natif du SDK. Le code qui s'en sert redevient lié à ce fournisseur, et cela se voit.

Relayer le flux en Server-Sent Events

Un modèle produit sa réponse jeton après jeton, et une réponse longue prend plusieurs secondes. Attendre la fin laisse l'utilisateur devant un écran vide ; relayer chaque morceau dès qu'il arrive affiche le premier mot presque tout de suite. Server-Sent Events est le format le plus simple pour cela : une réponse HTTP ordinaire, de type text/event-stream, qui ne se termine pas tout de suite. Son corps est une suite d'événements en texte, chacun fait de lignes champ: valeur — event: pour le type, data: pour la charge — et terminé par une ligne vide. Les API de fournisseurs, celle d'Anthropic par exemple, streament elles-mêmes dans ce format.

Écrire ce format à la main paraît trivial ; c'est là que se logent deux fautes.

using Microsoft.Extensions.AI;

// Program.cs : builder et AddChatClient comme dans l'exemple precedent.
var app = builder.Build();

app.MapPost("/api/chat", async (Question question, IChatClient chat, HttpContext contexte) =>
{
    contexte.Response.ContentType = "text/event-stream";

    // Aucun jeton d'annulation, ni ici ni a l'ecriture. Quand le navigateur
    // ferme la connexion, WriteAsync ne leve rien, et la boucle lit le modele
    // jusqu'a son dernier jeton.
    await foreach (var maj in chat.GetStreamingResponseAsync(question.Texte))
    {
        // Un morceau qui contient \n ferme la ligne data: en plein milieu.
        await contexte.Response.WriteAsync($"event: texte\ndata: {maj.Text}\n\n");
        await contexte.Response.Body.FlushAsync();
    }
});

app.Run();

public sealed record Question(string Texte);

La première est de cadrage : un morceau qui contient un saut de ligne clôt l'événement en plein milieu, et même un lecteur correct perd le retour à la ligne. La seconde coûte de l'argent : sans jeton d'annulation, la génération continue jusqu'au bout pour un lecteur qui n'existe plus. ASP.NET Core 10 fournit TypedResults.ServerSentEvents, qui règle la première, et le CancellationToken du gestionnaire règle la seconde.

using System.ComponentModel.DataAnnotations;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;

// Program.cs, suite de l'enregistrement de IChatClient.

// Lit les attributs de Question : une question bornee borne aussi les jetons
// d'entree qu'elle coute. Au-dela de 4000 caracteres, 400, sans appel au modele.
builder.Services.AddValidation();

var app = builder.Build();

// Le CancellationToken d'un gestionnaire est HttpContext.RequestAborted : il
// se declenche quand le client ferme la connexion.
app.MapPost("/api/chat", (Question question, IChatClient chat, CancellationToken ct) =>
    TypedResults.ServerSentEvents(Relayer(question, chat, ct)));

app.Run();

static async IAsyncEnumerable<SseItem<string>> Relayer(
    Question question,
    IChatClient chat,
    [EnumeratorCancellation] CancellationToken ct)
{
    // Le serveur compose la conversation : le navigateur n'envoie que la
    // question, jamais le prompt systeme ni le nom du modele.
    List<ChatMessage> messages =
    [
        new(ChatRole.System, "Tu reponds en francais, en trois phrases au plus."),
        new(ChatRole.User, question.Texte),
    ];

    await foreach (var maj in chat.GetStreamingResponseAsync(messages, cancellationToken: ct))
    {
        // Une mise a jour peut ne porter que des metadonnees — usage, raison
        // d'arret — et son Text est alors vide.
        if (maj.Text.Length > 0)
        {
            yield return new SseItem<string>(maj.Text, "texte");
        }
    }

    yield return new SseItem<string>("", "fin");
}

public sealed record Question([Required, StringLength(4000)] string Texte);

Un itérateur asynchrone reçoit son jeton par un paramètre marqué [EnumeratorCancellation], et Relayer le transmet à GetStreamingResponseAsync : ce passage relie la connexion du navigateur à la requête HTTP que le SDK a ouverte chez le fournisseur. La liaison de ce CancellationToken et celle de AddValidation sont détaillées dans le cours Construire une API. Voici la réponse réellement produite par cet endpoint.

# Endpoint precedent, derriere un IChatClient factice qui rejoue sept
# morceaux : "Déjà", " là", ",\n", "morceau", " par", " morceau", "."
$ curl -i -N -X POST http://localhost:5391/api/chat \
    -H "Content-Type: application/json" -d '{"texte":"Explique le streaming."}'
HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Fri, 25 Sep 2026 18:05:18 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Pragma: no-cache
Transfer-Encoding: chunked

event: texte
data: Déjà

event: texte
data:  là

event: texte
data: ,
data: 

event: texte
data: morceau

event: texte
data:  par

event: texte
data:  morceau

event: texte
data: .

event: fin
data: 

Trois détails s'y lisent. Le morceau qui contenait un saut de ligne est devenu deux lignes data:, que le lecteur recollera avec un \n. Le morceau " là" commence par une espace, si bien que sa ligne en porte deux après data: : la spécification n'en retire qu'une, la seconde est du texte. Enfin, Cache-Control: no-cache,no-store et Content-Encoding: identity sont posés par le résultat lui-même, sans une ligne de configuration.

Afficher le flux dans Angular

Le navigateur a une API dédiée, EventSource, mais elle n'envoie qu'un GET, sans corps ni en-tête personnalisé : ni la question, ni un jeton Bearer. fetch fait l'affaire, car Response.body est un ReadableStream qu'on lit paquet par paquet. Seulement, un paquet réseau n'est pas un événement : TCP, un proxy ou un équilibreur découpent le flux où ils veulent.

// Lit le corps paquet par paquet, comme si chaque paquet reseau contenait
// des evenements entiers.
export async function* lireTexte(reponse: Response): AsyncGenerator<string> {
  const lecteur = reponse.body!.getReader();
  while (true) {
    const { value, done } = await lecteur.read();
    if (done) return;
    // Quatre defauts. Un caractere UTF-8 coupe entre deux paquets est decode
    // en deux moities invalides ; une ligne coupee entre deux paquets est
    // perdue ; trim() retire l'espace de tete qui separait deux mots ; et les
    // lignes data: d'un meme evenement sortent une a une, au lieu d'etre
    // jointes par \n.
    const paquet = new TextDecoder().decode(value);
    for (const ligne of paquet.split('\n')) {
      if (ligne.startsWith('data:')) yield ligne.slice(5).trim();
    }
  }
}

// Corps reel de l'endpoint, texte attendu "Déjà là,\nmorceau par morceau." :
//   un evenement par paquet, comme en local : "Déjàlà,morceauparmorceau."
//   paquets de 24 octets                    : "Déj�l,parmorceau."

Ce lecteur a été rejoué sur le corps réel de l'endpoint. En local, où chaque événement arrive dans son propre paquet, deux défauts se voient déjà : le trim() colle les mots, et les deux lignes data: du morceau ",\n" sortent séparément, si bien que le retour à la ligne disparaît. Découpé en paquets de 24 octets, le même corps donne en plus un caractère de remplacement là où un à a été coupé en deux, et des lignes entières disparaissent. Ces deux défauts-là se voient rarement en local ; ils surgissent derrière l'intermédiaire qui redécoupe. La version juste garde un tampon et ne traite que des événements complets.

export interface EvenementSse {
  readonly type: string;
  readonly donnees: string;
}

// Un evenement se termine par une ligne vide, pas par la fin d'un paquet :
// le tampon garde ce qui n'est pas encore complet. TextDecoderStream decode en
// continu, si bien qu'un caractere coupe entre deux paquets est recolle. Le
// serveur ASP.NET Core separe ses lignes par \n ; un lecteur generique
// accepterait aussi \r\n et \r, que la specification autorise.
export async function* lireEvenements(reponse: Response): AsyncGenerator<EvenementSse> {
  const lecteur = reponse.body!.pipeThrough(new TextDecoderStream()).getReader();
  let tampon = '';
  while (true) {
    const { value, done } = await lecteur.read();
    if (done) return;
    tampon += value;
    let fin = tampon.indexOf('\n\n');
    while (fin !== -1) {
      yield analyser(tampon.slice(0, fin));
      tampon = tampon.slice(fin + 2);
      fin = tampon.indexOf('\n\n');
    }
  }
}

function analyser(bloc: string): EvenementSse {
  let type = 'message';
  const donnees: string[] = [];
  for (const ligne of bloc.split('\n')) {
    const deuxPoints = ligne.indexOf(':');
    const champ = deuxPoints === -1 ? ligne : ligne.slice(0, deuxPoints);
    let valeur = deuxPoints === -1 ? '' : ligne.slice(deuxPoints + 1);
    // La specification retire UNE espace apres les deux-points, pas davantage :
    // "data:  là" porte le texte " là".
    if (valeur.startsWith(' ')) valeur = valeur.slice(1);
    if (champ === 'event') type = valeur;
    if (champ === 'data') donnees.push(valeur);
  }
  // Plusieurs lignes data: forment une seule valeur, jointe par \n.
  return { type, donnees: donnees.join('\n') };
}

// Meme corps, un evenement par paquet ou paquets de 24 octets :
//   "Déjà là,\nmorceau par morceau."

Le composant n'a plus qu'à accumuler le texte dans un signal. Chaque update notifie la vue, et c'est cette notification qui la rafraîchit en zoneless : rien n'intercepte la lecture du flux, comme le détaille le cours Signals.

import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
// flux.ts : le lecteur lireEvenements de l'exemple precedent.
import { lireEvenements } from './flux';

@Component({
  selector: 'app-assistant',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <form (submit)="$event.preventDefault(); demander(question.value)">
      <textarea #question maxlength="4000"></textarea>
      <button type="submit">Demander</button>
      @if (etat() === 'en-cours') {
        <button type="button" (click)="arreter()">Arrêter</button>
      }
    </form>

    <p style="white-space: pre-wrap">{{ reponse() }}</p>
    @if (etat() === 'erreur') {
      <p role="alert">La réponse s'est interrompue.</p>
    }
  `,
})
export class Assistant {
  protected readonly reponse = signal('');
  protected readonly etat = signal<'repos' | 'en-cours' | 'erreur'>('repos');
  private controleur: AbortController | null = null;

  constructor() {
    // Quitter la page coupe la requete : cote serveur, RequestAborted se
    // declenche et la generation s'arrete.
    inject(DestroyRef).onDestroy(() => this.controleur?.abort());
  }

  protected arreter(): void {
    this.controleur?.abort();
  }

  protected async demander(question: string): Promise<void> {
    // Une nouvelle question annule la precedente, qui ecrirait sinon dans le
    // meme signal, entrelacee avec la nouvelle.
    this.controleur?.abort();
    const controleur = new AbortController();
    this.controleur = controleur;
    this.reponse.set('');
    this.etat.set('en-cours');

    try {
      const reponse = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ texte: question }),
        signal: controleur.signal,
      });
      if (!reponse.ok) throw new Error(`HTTP ${reponse.status}`);

      for await (const evenement of lireEvenements(reponse)) {
        // Chaque ecriture dans le signal marque la vue : en zoneless, c'est
        // elle qui planifie le rafraichissement, morceau apres morceau.
        if (evenement.type === 'texte') this.reponse.update((texte) => texte + evenement.donnees);
      }
      this.etat.set('repos');
    } catch {
      // abort() fait rejeter fetch ou la lecture en cours. Une annulation
      // voulue n'est pas une panne ; et si une autre question a pris la main,
      // l'etat lui appartient.
      if (this.controleur !== controleur) return;
      this.etat.set(controleur.signal.aborted ? 'repos' : 'erreur');
    }
  }
}

Annuler de bout en bout

L'annulation traverse trois frontières, et une seule coupure suffit à la perdre. Côté Angular, AbortController.abort() ferme la connexion : le composant l'appelle quand l'utilisateur arrête, quand une nouvelle question remplace l'ancienne, et à sa destruction. Côté ASP.NET Core, la fermeture déclenche HttpContext.RequestAborted, que le gestionnaire reçoit comme CancellationToken. Côté SDK, ce jeton annule la requête et ferme la connexion vers le fournisseur. La plupart arrêtent alors la génération, mais les jetons déjà produits restent facturés, et ce qui se passe au-delà relève de chaque fournisseur. Voici trois variantes mesurées derrière le même client factice.

# Le meme IChatClient factice : sept morceaux, 300 ms avant chacun, et un
# journal ecrit par le factice lui-meme. Pour chaque variante, le client
# coupe la connexion au bout d'une seconde :
#   curl -N --max-time 1 -X POST ... -d '{"texte":"x"}'

# Relais ecrit a la main, sans jeton (premier exemple de la section precedente)
factice : morceau 1 produit
...
factice : morceau 7 produit
factice : fin apres 7 morceaux, annule = False

# TypedResults.ServerSentEvents, mais Relayer sans parametre de jeton
factice : morceau 1 produit
...
factice : morceau 4 produit
factice : fin apres 4 morceaux, annule = False

# Endpoint de la section precedente, jeton transmis jusqu'au client IA
factice : morceau 1 produit
factice : morceau 2 produit
factice : morceau 3 produit
factice : fin apres 3 morceaux, annule = True

Le relais écrit à la main produit ses sept morceaux pour personne. La variante intermédiaire trompe davantage. TypedResults.ServerSentEvents parcourt l'itérateur avec RequestAborted et écrit chaque morceau avec ce jeton ; mais sans paramètre [EnumeratorCancellation], l'itérateur ignore le jeton qu'on lui passe, et c'est l'écriture du morceau suivant qui échoue et le libère. La génération s'arrête donc un morceau trop tard, sans que le client IA ait jamais été annulé. Tant que les morceaux se suivent de près, l'écart est faible ; mais avec trois secondes d'attente avant le premier mot, cette variante a laissé la génération courir près de deux secondes de plus que l'endpoint juste, jusqu'à ce premier mot que personne n'a lu. Côté navigateur, l'annulation fait rejeter fetch ou la lecture en cours : le composant la distingue d'une panne en consultant signal.aborted.

Jetons, plafonds et cache

Un modèle se facture au jeton, en entrée et en sortie, à des tarifs différents. Un jeton est un fragment de texte dont la taille dépend du tokenizer : un même texte ne compte pas le même nombre de jetons d'un modèle à l'autre, et un devis calculé pour l'un ne vaut pas pour l'autre. Trois leviers tiennent le coût : borner ce qui entre, plafonner ce qui sort, compter ce qui passe. La question est déjà bornée par [StringLength(4000)], et le nombre de questions par une limitation de débit, que le cours Qualité d'API détaille avec Retry-After. Pour mesurer avant d'envoyer, certains fournisseurs exposent un comptage : Anthropic, par exemple, /v1/messages/count_tokens, gratuit, dont le résultat est une estimation. Plafond et comptage se posent au mieux dans un maillon de la chaîne IChatClient, qu'aucun endpoint ne peut oublier.

using System.Runtime.CompilerServices;
using System.Threading.RateLimiting;
using Microsoft.Extensions.AI;

// Program.cs : l'enregistrement de IChatClient gagne un maillon.
builder.Services.AddChatClient(client)
    .Use((interne, services) => new ChatClientComptable(
        interne, services.GetRequiredService<ILogger<ChatClientComptable>>()))
    .UseLogging();

// Un seau par utilisateur : cinq questions d'avance, puis une toutes les
// trente secondes. Au-dela, 429, et le modele n'est pas appele. Sans
// authentification, tous les anonymes partagent le seau "anonyme".
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("ia", contexte => RateLimitPartition.GetTokenBucketLimiter(
        contexte.User.Identity?.Name ?? "anonyme",
        _ => new TokenBucketRateLimiterOptions
        {
            TokenLimit = 5,
            TokensPerPeriod = 1,
            ReplenishmentPeriod = TimeSpan.FromSeconds(30),
            QueueLimit = 0,
        }));
});
// Apres Build : app.UseRateLimiter(); et sur l'endpoint : .RequireRateLimiting("ia");

public sealed class ChatClientComptable(IChatClient interne, ILogger<ChatClientComptable> journal)
    : DelegatingChatClient(interne)
{
    private const int PlafondSortie = 800;

    public override async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        var reponse = await base.GetResponseAsync(messages, Plafonner(options), cancellationToken);
        Consigner(reponse.Usage);
        return reponse;
    }

    public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        await foreach (var maj in base.GetStreamingResponseAsync(
            messages, Plafonner(options), cancellationToken))
        {
            // En flux, l'usage voyage dans une mise a jour qui ne porte pas de
            // texte, sous la forme d'un UsageContent.
            foreach (var usage in maj.Contents.OfType<UsageContent>())
            {
                Consigner(usage.Details);
            }

            yield return maj;
        }
    }

    // Le plafond est impose ici, pour tous les appelants : un endpoint qui
    // l'oublierait ne peut pas le lever.
    private static ChatOptions Plafonner(ChatOptions? options)
    {
        var copie = options?.Clone() ?? new ChatOptions();
        copie.MaxOutputTokens = Math.Min(copie.MaxOutputTokens ?? PlafondSortie, PlafondSortie);
        return copie;
    }

    private void Consigner(UsageDetails? usage)
    {
        if (usage is null) return;
        journal.LogInformation(
            "Jetons : {Entree} en entree, dont {EnCache} lus en cache ; {Sortie} en sortie",
            usage.InputTokenCount,
            usage.CachedInputTokenCount,
            usage.OutputTokenCount);
    }
}

L'usage réel arrive avec la réponse. En flux, mesurés contre un faux serveur local, les deux adaptateurs le livrent dans une dernière mise à jour sans texte, sous forme de UsageContent ; l'adaptateur OpenAI ajoute de lui-même stream_options.include_usage à la requête pour l'obtenir. Journaliser ces nombres est ce qui permet, plus tard, de savoir où part le budget ; les rattacher à un utilisateur demande d'ajouter son identité à l'entrée de journal, ce que ce maillon ne fait pas.

Le cache de prompt est le dernier levier, et c'est celui qui varie le plus d'un fournisseur à l'autre. Le principe est commun : quand plusieurs requêtes commencent par le même préfixe exact — outils, prompt système, début d'historique —, le fournisseur réutilise le calcul de ce préfixe et facture les jetons lus en cache à une fraction du tarif. La conséquence pour le code l'est aussi : ce qui est stable va en tête, identique à l'octet près ; ce qui varie — la date, le nom de l'utilisateur, la question — va à la fin. Une date écrite en tête du prompt système suffit à rendre différent, d'une requête à l'autre, tout ce qui la suit.

En septembre 2026AnthropicOpenAI
Activationdemandée : cache_control sur la requête ou sur un blocpar défaut, sans rien demander
Préfixe minimalde 512 à 4096 jetons selon le modèle1024 jetons pour GPT-5.6 et suivants ; avant, selon les réglages de la requête
Lecture en cache0,1 × le tarif d'entrée, parfois moins0,1 × le tarif d'entrée pour GPT-5.6 et suivants
Écriture en cache1,25 × pour 5 minutes, 2 × pour une heure1,25 × pour GPT-5.6 et suivants ; avant, pas de surcoût d'écriture
Champ d'usagecache_read_input_tokensinput_tokens_details.cached_tokens (API Responses)

Le relevé d'usage le montre. Chez Anthropic, cache_read_input_tokens et cache_creation_input_tokens s'ajoutent à input_tokens, qui ne compte plus que ce qui suit le dernier point de cache ; l'exemple chiffré de la documentation donne cent mille jetons relus pour cinquante facturés plein tarif. Voici un relevé tel que la documentation le reproduit. Côté .NET, mesuré contre un faux serveur local, l'adaptateur Anthropic additionne les trois compteurs dans UsageDetails.InputTokenCount et range les jetons relus dans CachedInputTokenCount ; celui d'OpenAI remplit ce dernier depuis prompt_tokens_details.cached_tokens, le champ de Chat Completions.

{
  "usage": {
    "input_tokens": 2048,
    "cache_read_input_tokens": 1800,
    "cache_creation_input_tokens": 248,
    "output_tokens": 503,

    "cache_creation": {
      "ephemeral_5m_input_tokens": 148,
      "ephemeral_1h_input_tokens": 100
    }
  }
}

Tester sans réseau

Un test qui appelle un vrai modèle est lent, payant et non déterministe : la même question ne rend pas deux fois le même texte. Ce que fait le serveur, en revanche, est déterministe — les messages qu'il compose, les options qu'il impose, le cadrage du flux, les refus — et c'est cela qu'on teste. L'abstraction le rend facile : un client factice de quelques lignes remplace le fournisseur.

using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;

// Un IChatClient ecrit pour les tests : il rejoue des morceaux fixes, retient
// ce qu'on lui a envoye et ne touche pas au reseau.
public sealed class ChatClientFactice(params string[] morceaux) : IChatClient
{
    public IReadOnlyList<ChatMessage> MessagesRecus { get; private set; } = [];
    public ChatOptions? OptionsRecues { get; private set; }

    public async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        List<ChatResponseUpdate> majs = [];
        await foreach (var maj in GetStreamingResponseAsync(messages, options, cancellationToken))
        {
            majs.Add(maj);
        }
        return majs.ToChatResponse();
    }

    public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        MessagesRecus = [.. messages];
        OptionsRecues = options;

        foreach (var morceau in morceaux)
        {
            // Rend la main a chaque morceau, comme un vrai flux reseau.
            await Task.Yield();
            cancellationToken.ThrowIfCancellationRequested();
            yield return new ChatResponseUpdate(ChatRole.Assistant, morceau);
        }

        // Comme les adaptateurs reels, l'usage arrive a la fin, sans texte.
        yield return new ChatResponseUpdate
        {
            Role = ChatRole.Assistant,
            Contents = [new UsageContent(new UsageDetails { InputTokenCount = 20, OutputTokenCount = morceaux.Length })],
        };
    }

    public object? GetService(Type serviceType, object? serviceKey = null) =>
        serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;

    public void Dispose() { }
}

WebApplicationFactory, que présente le cours Tests d'intégration, démarre l'application entière en mémoire, ConfigureTestServices remplace le IChatClient enregistré, et SseParser, de System.Net.ServerSentEvents, relit le flux comme le ferait un client.

using System.Net;
using System.Net.Http.Json;
using System.Net.ServerSentEvents;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;

// Paquet Microsoft.AspNetCore.Mvc.Testing ; xUnit v2, celui du modele
// dotnet new xunit du SDK 10.0.300.
public sealed class ChatTests
{
    private static WebApplicationFactory<Program> Usine(ChatClientFactice factice) =>
        new WebApplicationFactory<Program>().WithWebHostBuilder(hote =>
        {
            // Program.cs exige une configuration complete au demarrage ; ces
            // valeurs ne servent qu'a construire un client qui ne sera jamais
            // appele, puisque le factice le remplace.
            hote.UseSetting("Ia:Fournisseur", "openai");
            hote.UseSetting("Ia:Modele", "modele-de-test");
            hote.UseSetting("Ia:Cle", "cle-de-test");
            hote.ConfigureTestServices(services => services.AddChatClient(factice));
        });

    [Fact]
    public async Task Le_flux_relaye_chaque_morceau_dans_l_ordre()
    {
        var factice = new ChatClientFactice("Déjà", " là", ",\n", "morceau", ".");
        await using var usine = Usine(factice);
        using var http = usine.CreateClient();

        using var reponse = await http.PostAsJsonAsync("/api/chat", new { texte = "Bonjour" });

        Assert.Equal("text/event-stream", reponse.Content.Headers.ContentType?.MediaType);
        var texte = new StringBuilder();
        var types = new List<string>();
        var flux = await reponse.Content.ReadAsStreamAsync();
        await foreach (var ev in SseParser.Create(flux).EnumerateAsync())
        {
            types.Add(ev.EventType);
            if (ev.EventType == "texte") texte.Append(ev.Data);
        }

        Assert.Equal("Déjà là,\nmorceau.", texte.ToString());
        Assert.Equal("fin", types[^1]);
        // Ce que le serveur envoie au modele se verifie, lui, exactement.
        Assert.Equal(ChatRole.System, factice.MessagesRecus[0].Role);
        Assert.Equal("Bonjour", factice.MessagesRecus[^1].Text);
    }

    [Fact]
    public async Task Une_question_trop_longue_est_refusee_sans_appeler_le_modele()
    {
        var factice = new ChatClientFactice("jamais");
        await using var usine = Usine(factice);
        using var http = usine.CreateClient();

        using var reponse = await http.PostAsJsonAsync(
            "/api/chat", new { texte = new string('x', 4001) });

        Assert.Equal(HttpStatusCode.BadRequest, reponse.StatusCode);
        Assert.Empty(factice.MessagesRecus);
    }

    [Fact]
    public async Task Le_comptable_plafonne_la_sortie_meme_si_l_appelant_demande_plus()
    {
        var factice = new ChatClientFactice("ok");
        var comptable = new ChatClientComptable(factice, NullLogger<ChatClientComptable>.Instance);

        await comptable.GetResponseAsync("Bonjour", new ChatOptions { MaxOutputTokens = 100_000 });

        Assert.Equal(800, factice.OptionsRecues?.MaxOutputTokens);
    }
}

// dotnet test : 3 tests reussis, sans reseau ni cle.

Le troisième test porte sur le maillon seul, sans hôte : c'est l'intérêt d'avoir mis le plafond dans un DelegatingChatClient. La qualité des réponses du modèle, elle, ne se vérifie pas ainsi ; elle relève de l'évaluation, avec un jeu de cas et des critères, que traite le cours Prompts.

Ce cours vous a servi ? Offrir un café Signaler une erreur