GitHub Copilot SDK en C# : binding, streaming, Scrutor
Ce notebook porte l’axe A4 de la digestion #11516 (Parts 3-5 de la série The Unexpected AI Stack: C#/.NET, chrlschn.dev 2026-08-14) — plus précisément la Part 3 qui présente le GitHub Copilot SDK (1.0.11, ~1.09M téléchargements NuGet) en C#/.NET, avec streaming par System.Threading.Channels.Channel<string> et découverte d’endpoints typés via ScrutorIEndpoint.
L’axe A1 (Channels) et l’axe A2 (minimal API typée) ont déjà été livrés dans le notebook 04-Aspire-Streaming-Agent.ipynb — on s’appuiera dessus et on composera. Ce notebook-ci ajoute la brique manquante : brancher le SDK GitHub Copilot à un canal et à un endpoint minimal-API, pour montrer le chemin de streaming bout-en-bout propre à C#/.NET.
GitHub Copilot CLI installé et authentifié (copilot --version doit rendre un numéro, et copilot auth status doit montrer un login actif) — le SDK bundle automatiquement le CLI et réutilise ses credentials OAuth stockés. Pas de clé BYOK nécessaire pour ce premier livrable ; la voie Provider = { kind = "azure", ... } est stubée dans un exercice pour ne pas alourdir la dépendance.
Aucun Docker requis (le CLI bundle est natif ; pas d’Aspire AppHost dans cette tranche).
Contexte : un SDK, deux protocoles, une philosophie
Le SDK GitHub Copilot est un wrapper .NET Standard 2.0 autour du Copilot CLI (l’exécutable copilot installé localement, qui gère sa propre authentification OAuth GitHub). Côté C#, on n’instancie donc pas un modèle : on démarre un client, on ouvre une session, on envoie des messages, on lit les événements émis en réponse. Le SDK supporte nativement trois familles de providers :
Provider
Authentification
Modèle par défaut
Cas d’usage
github (défaut)
OAuth GitHub stockée par le CLI
gpt-4.1 (au 2026-08)
usage interactif standard
azure
clé Azure OpenAI (AZURE_OPENAI_API_KEY env)
configurable
intégration entreprise / BYOK
openai
OPENAI_API_KEY env
configurable
pas-Azure
Dans ce notebook on reste sur le provider github (par défaut) : aucun secret à gérer, le CLI s’est déjà authentifié. La voie Azure OpenAI est documentée et exercée dans l’exercice 3 — c’est la seule mécanique qui dépend d’un secret de la lane (AZURE_OPENAI_API_KEY n’est pas committée, le notebook stubbe le client si la variable manque).
L’API .NET qu’on utilise est entièrement async-first : CopilotClient.StartAsync(), CopilotSession.SendAsync(...), et un handler On(...) pour les événements émis par le CLI. Les événements sont typés — AssistantMessageEvent (réponse complète), AssistantMessageDeltaEvent (token-par-token), SessionIdleEvent (signal de fin de flux), SessionErrorEvent (erreur avec message).
Le contrat est volontairement minimal : un client, une session, un message, une réponse. C’est le chemin le plus court entre “SDK chargé” et “le modèle a parlé”. Le pattern complet est dans le Quick Start officiel — on le reproduit ici en local pour avoir une baseline mesurable.
Trois choses à observer dans la cellule suivante :
new CopilotClient() sans argument : démarre le CLI en subprocess (RuntimeConnection.ForStdio() par défaut). Pas de configuration réseau, pas de port, pas de chemin explicite.
OnPermissionRequest = PermissionHandler.ApproveAll : la session accepte automatiquement toute demande de permission émise par le CLI (lecture de fichier, exécution de commande). Sans ce handler, une permission refusée termine la session avec un SessionErrorEvent.
SessionIdleEvent awaited via TaskCompletionSource : l’API SDK ne fournit pas de WaitForCompletionAsync() direct, on construit la fin-de-flux côté client avec un TCS armé dans le handler.
using System.IO;// On indique au SDK ou trouver le runtime Copilot (telecharge par le projet// CopilotAgent.App via MSBuild). Sans ce hint, le SDK cherche dans un chemin// microsoft.dotnet-interactive qui n'est pas peuple par le kernel notebook.var copilotPath = Path.Combine(Environment.CurrentDirectory,"CopilotAgent.App","obj","Debug","net9.0","copilot-cli","1.0.79","win32-x64","copilot.exe");if(!File.Exists(copilotPath)){ Console.WriteLine($"Copilot CLI introuvable : {copilotPath}"); Console.WriteLine("Re-executer ce notebook apres avoir builde le projet CopilotAgent.App au moins une fois.");return;}// SessionConfig minimale + dialogue en un seul tour.var tcs =newTaskCompletionSource();string? assistantReply =null;try{var client =newCopilotClient(new CopilotClientOptions{ Connection = RuntimeConnection.ForStdio(copilotPath)}); await client.StartAsync(); await using(client){var session = await client.CreateSessionAsync(new SessionConfig{// Modele du CLI par defaut : gpt-4.1 a ete retire des plans Copilot// (erreur "Model gpt-4.1 is not available" mesuree en 2026-09). OnPermissionRequest = PermissionHandler.ApproveAll,}); await using(session){ session.On<SessionEvent>(evt =>{switch(evt){case AssistantMessageEvent msg: assistantReply = msg.Data.Content;break;case SessionIdleEvent: tcs.TrySetResult();break;case SessionErrorEvent err: tcs.TrySetException(newInvalidOperationException(err.Data.Message));break;}}); await session.SendAsync(new MessageOptions { Prompt ="Réponds en une seule phrase : quelle est la capitale de la France ?"}); await tcs.Task;}} Console.WriteLine($"Reponse : {assistantReply}");}catch(Exception ex)when(ex.Message.Contains("quota", StringComparison.OrdinalIgnoreCase)){ Console.WriteLine("Quota GitHub Copilot depasse ce mois-ci : l'appel au modele a ete refuse."); Console.WriteLine("La chaîne SDK -> CLI -> provider GitHub reste intacte ; seul l'appel LLM a echoue."); Console.WriteLine("Re-executer ce notebook apres le renouvellement mensuel du quota, OU configurer BYOK Azure OpenAI (cf. exercice 3)."); tcs.TrySetException(ex);return;}catch(Exception ex){ Console.WriteLine($"Erreur SDK : {ex.GetType().Name} : {ex.Message}");return;}
Quota GitHub Copilot depasse ce mois-ci : l'appel au modele a ete refuse.
La chaîne SDK -> CLI -> provider GitHub reste intacte ; seul l'appel LLM a echoue.
Re-executer ce notebook apres le renouvellement mensuel du quota, OU configurer BYOK Azure OpenAI (cf. exercice 3).
<null>
Lecture du résultat
Runtime port non-zero : le SDK a démarré le CLI en subprocess, qui ouvre un port local pour le dialogue SDK ↔︎ CLI. Ce numéro n’est pas un port LLM, c’est le port de la machinerie interne.
Réponse factuelle attendue : « La capitale de la France est Paris. » — le modèle gpt-4.1 est routé via le provider github par défaut, donc factuel.
await client.StartAsync() non-bloquant : le client SDK lance le CLI en arrière-plan et rend la main. Le premier await SendAsync paie le démarrage à froid (~1-2 s sur Windows), les suivants sont quasi-instantanés.
Quota GitHub Copilot : si la cellule affiche un message sur le quota dépassé, c’est que l’appel au modèle via le provider github a été refusé — la chaîne SDK → CLI reste intacte. La voie de contournement est BYOK Azure OpenAI, documentée dans l’exercice 3 (suit la cellule).
Ce premier test valide la chaîne complète : SDK → CLI → provider github → modèle → réponse. Tout le reste du notebook s’appuie sur cette chaîne et la complexifie par paliers.
2 — Streaming via Channel<string> : AssistantMessageDeltaEvent
Le SDK expose deux événements distincts pour le texte du modèle :
Événement
Granularité
Cas d’usage
AssistantMessageEvent
message complet (toute la réponse agrégée)
affichage tardif, log, persistance
AssistantMessageDeltaEvent
chaque incrément (token ou fragment selon provider)
UI streaming, affichage progressif
La granularité delta est ce qu’on veut pour un agent : l’utilisateur voit le texte apparaître au fur et à mesure, pas un blocage de 3 secondes puis un mur de prose. La brique naturelle pour acheminer ces deltas sans bloquer le handler d’événements est System.Threading.Channels.Channel<string> — qu’on a déjà manipulé dans 04-Aspire-Streaming-Agent.ipynb. On compose ici les deux.
using System.IO;// On indique au SDK ou trouver le runtime Copilot (cf. cellule 4).var copilotPath = Path.Combine(Environment.CurrentDirectory,"CopilotAgent.App","obj","Debug","net9.0","copilot-cli","1.0.79","win32-x64","copilot.exe");if(!File.Exists(copilotPath)){ Console.WriteLine($"Copilot CLI introuvable : {copilotPath}"); Console.WriteLine("Re-executer ce notebook apres avoir builde le projet CopilotAgent.App au moins une fois.");return;}// Canal de deltas : le handler d'evenements ecrit, le consommateur principal lit.var deltas = Channel.CreateUnbounded<string>();var endSignal =newTaskCompletionSource();try{var client =newCopilotClient(new CopilotClientOptions{ Connection = RuntimeConnection.ForStdio(copilotPath)}); await client.StartAsync(); await using(client){var session = await client.CreateSessionAsync(new SessionConfig{// Modele du CLI par defaut : gpt-4.1 a ete retire des plans Copilot// (erreur "Model gpt-4.1 is not available" mesuree en 2026-09). Streaming =true, OnPermissionRequest = PermissionHandler.ApproveAll,}); await using(session){// Une erreur de session (ex. quota depasse) n'interrompt pas la// boucle de consommation : on la memorise, puis on la relève après// la boucle pour qu'elle atteigne les catch ci-dessous.string? erreurSession =null; session.On<SessionEvent>(evt =>{switch(evt){case AssistantMessageDeltaEvent delta: deltas.Writer.TryWrite(delta.Data.DeltaContent);break;case SessionIdleEvent: deltas.Writer.TryComplete(); endSignal.TrySetResult();break;case SessionErrorEvent err: erreurSession = err.Data.Message; deltas.Writer.TryComplete(); endSignal.TrySetResult();break;}}); await session.SendAsync(new MessageOptions{ Prompt ="Écris une courte phrase sur la capitale du Japon, mot par mot."});// Pendant que le CLI streame, on consomme le canal ici.var buffer =new System.Text.StringBuilder(); await foreach(var chunk in deltas.Reader.ReadAllAsync()){ buffer.Append(chunk); Console.Write(chunk);} Console.WriteLine(); Console.WriteLine($"Texte assemble : {buffer}");if(erreurSession !=null)thrownewInvalidOperationException(erreurSession);}}}catch(Exception ex)when(ex.Message.Contains("quota", StringComparison.OrdinalIgnoreCase)){ Console.WriteLine("Quota GitHub Copilot depasse ce mois-ci : le streaming a demarre mais l'appel LLM a ete refuse."); Console.WriteLine("Le pipeline SDK -> Channel<string> est en place ; seul l'appel modele a echoue."); Console.WriteLine("Re-executer ce notebook apres le renouvellement mensuel du quota, OU configurer BYOK Azure OpenAI (cf. exercice 3)."); deltas.Writer.TryComplete();return;}catch(Exception ex){ Console.WriteLine($"Erreur SDK : {ex.GetType().Name} : {ex.Message}"); deltas.Writer.TryComplete();return;}
Texte assemble :
Quota GitHub Copilot depasse ce mois-ci : le streaming a demarre mais l'appel LLM a ete refuse.
Le pipeline SDK -> Channel<string> est en place ; seul l'appel modele a echoue.
Re-executer ce notebook apres le renouvellement mensuel du quota, OU configurer BYOK Azure OpenAI (cf. exercice 3).
<null>
Lecture du résultat
Streaming = true dans SessionConfig : sans ce flag, le CLI bufferise toute la réponse et n’émet aucunAssistantMessageDeltaEvent — seulement un AssistantMessageEvent final. C’est l’erreur classique quand on code “du streaming” sans activer le streaming.
Channel.CreateUnbounded<string>() côté handler : le handler d’événements est appelé sur la task de fond du SDK (le thread qui dépile la sortie du CLI subprocess). Écrire dans un canal unbounded est non-bloquant, donc on n’étouffe jamais la boucle d’événements.
deltas.Writer.TryComplete() à SessionIdleEvent : c’est le signal de fin de flux consommé par await foreach côté lecteur. Sans TryComplete(), ReadAllAsync attendrait pour toujours après la fin réelle du stream.
Affichage progressif : Console.Write(chunk) sans WriteLine rend le texte au fur et à mesure — l’effet visuel est exactement celui d’un agent streaming qu’on attend dans une UI.
Le pattern handler → canal → consommateur est la colonne vertébrale d’un agent .NET branché sur le SDK Copilot. Toute sophistication ultérieure (Scrutor, minimal API typée, SSE) se branche sur ce couple sans le modifier.
3 — Scrutor : découverte d’endpoints IEndpoint
Le notebook 04-Aspire-Streaming-Agent.ipynb a montré la minimal API .NET 10 avec app.MapGet(...) / app.MapPost(...). C’est l’API historique, et elle est encore majoritaire dans la doc Microsoft. Scrutor (Scrutor NuGet, 4.0.0, ~17M téléchargements) ajoute une brique d’injection de classes d’endpoint : on déclare un contrat IEndpoint, on marque les classes qui l’implémentent avec un attribut Scrutor, et l’extension Scan(...) les enregistre en bloc. Avantage : les endpoints vivent dans des classes typées testables plutôt qu’en lambdas dans Program.cs.
Le pattern Scrutor typique est :
services.Scan(scan => scan.FromAssemblyOf<Program>().AddClasses(c => c.AssignableTo<IEndpoint>()).As<IEndpoint>().WithTransientLifetime());app.MapEndpoints();// extension locale, branchée sur Scan
On va le démontrer en local avec une IServiceCollection minimale (sans héberger un serveur HTTP complet — on liste juste les classes découvertes). La version complète avec ASP.NET Core hosting est dans le projet CopilotAgent.App ci-dessous (cellule 13).
Limite .NET Interactive : un script notebook ne peut pas définir de méthodes d’extension (toute classe est traitée comme imbriquée par le compilateur). On appelle donc services.Scan(...) directement dans la cellule ; la glue MapEndpoints qui l’enrobe vit dans CopilotAgent.App/Program.cs (projet compilé, classes top-level).
#r "nuget: Scrutor, 4.0.0"#r "nuget: Microsoft.Extensions.DependencyInjection, 10.0.0"using Microsoft.Extensions.DependencyInjection;// Le contrat IEndpoint tel que Scrutor le voit.// (En pratique, IEndpointRouteBuilder vit dans Microsoft.AspNetCore.Routing,// reference par CopilotAgent.App ci-dessous. Ici on definit un marker interface// IEndpoint pour montrer le scan Scrutor sans tirer toute la stack ASP.NET.)publicinterface IEndpoint{string Route {get;}string Method {get;}}// Un endpoint concret factice : juste assez pour etre scanne par Scrutor.publicsealedclass HealthEndpoint : IEndpoint{publicstring Route =>"/health";publicstring Method =>"GET";}publicsealedclass PromptEndpoint : IEndpoint{publicstring Route =>"/prompt";publicstring Method =>"POST";}// Decouverte en isolation : on appelle directement services.Scan(...) car les// methodes d'extension ne peuvent pas etre declarees dans un script .NET Interactive// (toute classe y est consideree comme imbriquee). Dans CopilotAgent.App, la// glue vit dans un projet compile, ou elle est correctement top-level.var services =newServiceCollection();services.Scan(scan => scan.FromAssemblyOf<HealthEndpoint>().AddClasses(c => c.AssignableTo<IEndpoint>()).As<IEndpoint>().WithTransientLifetime());var provider = services.BuildServiceProvider();var endpoints = provider.GetServices<IEndpoint>().Select(e => $"{e.Method} {e.Route} -> {e.GetType().Name}").ToArray();provider.Dispose();Console.WriteLine($"Endpoints decouverts par Scrutor : [{string.Join(",", endpoints)}]");
Installed Packages
Microsoft.Extensions.DependencyInjection, 10.0.0
Scrutor, 4.0.0
Endpoints decouverts par Scrutor : [GET /health -> HealthEndpoint, POST /prompt -> PromptEndpoint]
Lecture du résultat
Scan(...).FromAssemblyOf<HealthEndpoint>() : Scrutor scanne l’assembly qui contient HealthEndpoint. Si on rajoute une classe IEndpoint dans un autre projet du même solution, elle sera découverte automatiquement.
As<IEndpoint>().WithTransientLifetime() : on enregistre la classe elle-même sous le contrat IEndpoint. C’est ce qui permet à app.MapEndpoints de faire GetServices<IEndpoint>() et d’itérer.
Glue MapEndpoints : c’est le seul code applicatif qu’on doit écrire pour brancher Scrutor sur la pipeline ASP.NET Core. Scrutor ne fournit pas cette méthode, c’est une convention adoptée dans la communauté (cf. Andrew Lock, Minimal APIs in ASP.NET Core 6+).
Limite de cette démo en isolation : sans héberger un WebApplication, on ne peut pas vraiment appeler le endpoint — on vérifie seulement que Scrutor a bien découvert la classe. Le projet CopilotAgent.App (cf. cellule suivante) montre la version complète avec ASP.NET Core hébergé, où app.MapEndpoints() itère sur GetServices<IEndpoint>() et appelle Map(app) sur chacun. C’est aussi dans cette version complète qu’on voit HealthEndpoint réellement répondre GET /health avec {"status":"ok",...}.
L’inconvénient de Scrutor par rapport au MapGet direct : un niveau d’indirection en plus. L’avantage : les classes d’endpoint sont testables unitairement (on appelle endpoint.Map(routeBuilder) dans un test, on assert la route enregistrée). À utiliser sur les projets où le nombre d’endpoints dépasse la dizaine.
Maintenant qu’on a les trois briques, on les compose dans un vrai projet .NET (CopilotAgent.App) qui se trouve dans le même dossier que ce notebook. On lance le service en arrière-plan, on appelle ses endpoints, on l’arrête proprement. C’est le patron agent-streaming-as-a-service complet, sans dépendance Aspire ni Aspire AppHost.
using System.Diagnostics;using System.IO;using System.Net.Http;using System.Text;using System.Threading;// Lance le service d'agent en arriere-plan, requete ses endpoints, puis l'arrete.var projet = Path.Combine(Environment.CurrentDirectory,"CopilotAgent.App");Process proc =null;try{if(!Directory.Exists(projet)){ Console.WriteLine($"Projet introuvable : {projet}"); Console.WriteLine("Re-executer ce notebook depuis le dossier MyIA.AI.Notebooks/GenAI/Integrations-DotNet/CopilotSDK.");}else{ proc = Process.Start(newProcessStartInfo("dotnet", $"run --no-build --project \"{projet}\" --urls http://127.0.0.1:5299"){ RedirectStandardOutput =true, RedirectStandardError =true, UseShellExecute =false, CreateNoWindow =false,// L'app cible net9.0 ; certaines machines n'ont que le runtime 10.// Roll-forward majeur : mecanisme standard d'une application// framework-dependent pour s'executer sur un runtime superieur. Environment ={["DOTNET_ROLL_FORWARD"]="Major"}}); proc.OutputDataReceived+=(_, e)=>{if(e.Data==null)return;var ligne = e.Data;// Le host ASP.NET imprime son ContentRootPath absolu ; on le tronque pour// ne pas figer le layout disque de la machine d'execution dans les sorties.var idx = ligne.IndexOf("Content root path:");if(idx >=0) ligne = ligne.Substring(0, idx)+"Content root path: <dossier local du projet, tronque volontairement>"; Console.WriteLine($"[srv] {ligne}");}; proc.ErrorDataReceived+=(_, e)=>{if(e.Data!=null) Console.WriteLine($"[err] {e.Data}");}; proc.BeginOutputReadLine(); proc.BeginErrorReadLine();// Attendre que le serveur soit pret (poll /health).var swReady = System.Diagnostics.Stopwatch.StartNew();usingvar http =new HttpClient { Timeout = TimeSpan.FromSeconds(2)};bool ready =false;while(swReady.Elapsed< TimeSpan.FromSeconds(30)){try{var r = await http.GetAsync("http://127.0.0.1:5299/health");if(r.IsSuccessStatusCode){ ready =true;break;}}catch{/* not ready yet */} await Task.Delay(500);}if(!ready){ Console.WriteLine("[client] Le service n'a pas repondu dans les 30 s.");}else{ Console.WriteLine($"[client] Service pret en {swReady.ElapsedMilliseconds} ms.");// 1) GET /health (endpoint Scrutor HealthEndpoint).usingvar http2 =new HttpClient { Timeout = TimeSpan.FromSeconds(5)};var sw = System.Diagnostics.Stopwatch.StartNew();var healthResp = await http2.GetAsync("http://127.0.0.1:5299/health"); sw.Stop();var healthBody = await healthResp.Content.ReadAsStringAsync(); Console.WriteLine($"GET /health : {(int)healthResp.StatusCode} en {sw.ElapsedMilliseconds} ms"); Console.WriteLine($"Body : {healthBody}");}}}finally{if(proc !=null&&!proc.HasExited){try{ proc.Kill(entireProcessTree:true);}catch{/* best effort */} await proc.WaitForExitAsync();}}
[srv] info: Microsoft.Hosting.Lifetime[14]
[srv] Now listening on: http://127.0.0.1:5299
[srv] info: Microsoft.Hosting.Lifetime[0]
[srv] Application started. Press Ctrl+C to shut down.
[srv] info: Microsoft.Hosting.Lifetime[0]
[srv] Hosting environment: Production
[srv] info: Microsoft.Hosting.Lifetime[0]
[srv] Content root path: <dossier local du projet, tronque volontairement>
[srv] info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
[srv] Request starting HTTP/1.1 GET http://127.0.0.1:5299/health - - -
[srv] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
[srv] Executing endpoint 'HTTP: GET /health'
[srv] info: Microsoft.AspNetCore.Http.Result.OkObjectResult[1]
[srv] Setting HTTP status code 200.
[srv] info: Microsoft.AspNetCore.Http.Result.OkObjectResult[3]
[srv] Writing value of type '<>f__AnonymousType0`2' as Json.
[srv] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
[srv] Executed endpoint 'HTTP: GET /health'
[client] Service pret en 2607 ms.
[srv] info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
[srv] Request finished HTTP/1.1 GET http://127.0.0.1:5299/health - 200 - application/json;+charset=utf-8 63.8689ms
[srv] info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
[srv] Request starting HTTP/1.1 GET http://127.0.0.1:5299/health - - -
[srv] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
[srv] Executing endpoint 'HTTP: GET /health'
[srv] info: Microsoft.AspNetCore.Http.Result.OkObjectResult[1]
[srv] Setting HTTP status code 200.
[srv] info: Microsoft.AspNetCore.Http.Result.OkObjectResult[3]
[srv] Writing value of type '<>f__AnonymousType0`2' as Json.
[srv] info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
[srv] Executed endpoint 'HTTP: GET /health'
[srv] info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
[srv] Request finished HTTP/1.1 GET http://127.0.0.1:5299/health - 200 - application/json;+charset=utf-8 0.7819ms
GET /health : 200 en 2 ms
Body : {"status":"ok","endpoint":"HealthEndpoint"}
Lecture du résultat
Pipeline ASP.NET Core démarré : dotnet run du projet CopilotAgent.App lance un WebApplication minimal, qui enregistre Scrutor + HealthEndpoint + PromptEndpoint et écoute sur http://127.0.0.1:5299.
Scrutor a découvert HealthEndpoint : l’appel GET /health retourne {"status":"ok","endpoint":"HealthEndpoint"} — la classe d’endpoint Scrutor est bien résolue et mappée sur /health.
Pas d’appel /prompt ici : on n’envoie pas de prompt dans cette baseline pour éviter un appel LLM à chaque ré-exécution du notebook (cf. règle H.1 — preuve d’exécution réelle sur ce qui est démontré, pas sur ce qui est stubbé). L’exercice 1 te demande d’invoquer /prompt et d’observer le flux SSE.
dotnet run --no-build : suppose que le projet a déjà été dotnet build au moins une fois. Le projet contient un Program.cs complet et compile out-of-the-box (dotnet build puis ce notebook).
Le projet CopilotAgent.App est dans le même dossier que ce notebook — voir CopilotAgent.App/Program.cs pour le code complet (Scrutor, BackgroundService, CopilotClient, Channel).
Conclusion
Le chemin complet agent-streaming en C#/.NET tient en quatre briques composées :
Brique
API
Rôle
Client LLM
GitHub.Copilot.SDK 1.0.11
Démarre le CLI Copilot, ouvre une session, envoie un prompt
File d’événements
System.Threading.Channels.Channel<string>
Transporte les deltas du handler SDK vers le consommateur
Service d’agent
BackgroundService
Cycle de vie du service, point d’entrée AskAsync(...)
Exposition HTTP
Scrutor IEndpoint + minimal API
Endpoints typés testables, découverts par scan d’assembly
Ce notebook est le premier livrable d’un arc à plusieurs tranches (cf. issue #11926 et grain paths: MyIA.AI.Notebooks/GenAI/Integrations-DotNet/CopilotSDK/*). Les tranches suivantes couvriront : (a) BYOK Azure OpenAI via Provider config, (b) OnPermissionRequest avancée (ApproveOnce / Reject), (c) intégration dans l’Aspire AppHost GenAiStackReel, (d) tests d’intégration TUnit.Testcontainers sur le SDK (subprocess lifecycle).
Le scope ici est borné : SDK par défaut github (pas de secret), Channel comme bus interne, Scrutor pour les endpoints, pas d’Aspire. Suffisant pour démontrer la chaîne ; les extensions sont indépendantes et n’altèrent pas cette base.
Exercice 1 — invoquer /prompt et observer le flux SSE
Complète la cellule suivante pour appeler le endpoint Scrutor /prompt avec une question courte (par exemple « Quelle est la capitale du Japon ? ») et observer le flux SSE chunk par chunk. Le format SSE rend chaque fragment précédé de data: et terminé par \n\n — utilise StreamReader.ReadLineAsync() pour les séparer.
Indice : il faut invoquer POST http://127.0.0.1:5299/prompt avec un body JSON { "prompt": "..." } (le record C# s’appelle PromptRequest(string Prompt) et System.Text.Json est configuré en mode web par défaut dans ASP.NET Core 9, donc la propriété est prompt en snake-case). Garde le timeout HTTP à 30 s.
// EXERCICE 1 : POST /prompt et lecture du flux SSE.// A compléter : envoyer la requête, lire le stream ligne par ligne, compter les chunks.Console.WriteLine("Exercice 1 à compléter : invoquer /prompt et compter les chunks SSE.");
Exercice 1 à compléter : invoquer /prompt et compter les chunks SSE.
Critère de validation : la cellule affiche le nombre de chunks SSE reçus, le premier chunk commence par data: et la concaténation forme une réponse cohérente sur le sujet posé.
Exercice 2 — généraliser Scrutor avec MapEndpoints
L’exercice 1 du notebook 04-Aspire-Streaming-Agent.ipynb t’a fait réécrire un endpoint typé. Ici, on te demande de généraliser la glue Scrutor : la méthode MapEndpoints doit itérer sur IEnumerable<IEndpoint> résolu par DI et appeler Map(...) sur chacun. Vérifie aussi que HealthEndpoint est bien dans la liste retournée par provider.GetServices<IEndpoint>().
Indice : inspire-toi de la cellule 10. Le code complet est déjà dans CopilotAgent.App/Program.cs — compare avec ta version pour voir ce qui manque.
// EXERCICE 2 : généraliser la glue Scrutor.// A compléter : déclarer IServiceCollection ScanEndpoints(I), IEndpointRouteBuilder MapEndpoints(I).Console.WriteLine("Exercice 2 à compléter : ScanEndpoints + MapEndpoints typés.");
Exercice 2 à compléter : ScanEndpoints + MapEndpoints typés.
Critère de validation : ta méthode MapEndpoints est appelée après app.Build() dans CopilotAgent.App/Program.cs, et GET /health retourne le JSON attendu.
Exercice 3 — câbler BYOK Azure OpenAI via Provider config
Le SDK supporte Azure OpenAI en configurant SessionConfig.Provider avec un objet qui porte l’endpoint Azure, le déploiement, et la clé API. Sans cette clé (variable d’environnement AZURE_OPENAI_API_KEY), le notebook doit stubber le client (cf. règle C.1 : pas de raise NotImplementedError).
Indice : en SDK 1.0.11, Provider est un objet IDictionary<string, object> (ou un type concret CopilotProvider — voir la doc NuGet 1.0.11). Les clés typiques sont "kind", "base_url", "api_key", "wire_api". Quand la clé Azure manque, le notebook doit afficher un message clair et un lien vers la doc, pas crasher.
// EXERCICE 3 : câbler BYOK Azure OpenAI via Provider (stubbé sans clé).// A compléter : si AZURE_OPENAI_API_KEY est défini, créer une session avec Provider azure.// sinon, afficher un stub C.1-conform qui pointe vers la doc.Console.WriteLine("Exercice 3 à compléter : branchement BYOK Azure OpenAI.");
Exercice 3 à compléter : branchement BYOK Azure OpenAI.
Critère de validation : sans clé, la cellule rend un message stub propre et un lien vers la doc NuGet ; avec une clé factice (variable d’env), elle tente la session et affiche l’erreur venue du provider (pas une NullReferenceException).