33 *--------------------------------------------------------------------------------------------*/
44
55using System . Collections . Concurrent ;
6+ using System . Net ;
7+ using System . Net . Http ;
68using System . Text ;
79using System . Text . RegularExpressions ;
810
@@ -11,19 +13,27 @@ namespace GitHub.Copilot.Test.E2E;
1113#pragma warning disable GHCP001 // The LLM inference surface is intentionally experimental.
1214
1315/// <summary>
14- /// An <see cref="ILlmInferenceProvider "/> for e2e tests that records every
15- /// intercepted request (url + threaded session id) and fabricates well-formed
16- /// responses for every model-layer endpoint, so an agent turn completes
17- /// entirely off-network — no upstream server and no CAPI proxy acting as the
18- /// inference endpoint.
16+ /// A <see cref="LlmRequestHandler "/> subclass for e2e tests that records every
17+ /// intercepted request (url + threaded session id) and fully replaces the
18+ /// upstream call with a fabricated, well-formed response for every model-layer
19+ /// endpoint, so an agent turn completes entirely off-network — no upstream
20+ /// server and no CAPI proxy acting as the inference endpoint.
1921/// </summary>
2022/// <remarks>
23+ /// <para>
24+ /// This exercises the public extension surface end to end: a consumer subclasses
25+ /// <see cref="LlmRequestHandler"/> and overrides <see cref="ForwardAsync"/> to
26+ /// short-circuit the upstream HTTP call with any <see cref="HttpResponseMessage"/>
27+ /// it likes. The base class streams that response back to the runtime.
28+ /// </para>
29+ /// <para>
2130/// All response bodies are emitted as raw JSON string literals rather than via
2231/// <c>JsonSerializer</c>: the test project disables reflection-based STJ on
2332/// net8.0 (<c>JsonSerializerIsReflectionEnabledByDefault=false</c>), so
2433/// serializing anonymous types would throw at runtime.
34+ /// </para>
2535/// </remarks>
26- internal sealed class RecordingInferenceProvider : ILlmInferenceProvider
36+ internal sealed class RecordingInferenceProvider : LlmRequestHandler
2737{
2838 internal const string SyntheticText = "OK from the synthetic stream." ;
2939
@@ -36,18 +46,22 @@ internal sealed class RecordingInferenceProvider : ILlmInferenceProvider
3646 public IReadOnlyList < InterceptedRequest > InferenceRequests =>
3747 [ .. _records . Where ( r => IsInferenceUrl ( r . Url ) ) ] ;
3848
39- public async Task OnLlmRequestAsync ( LlmInferenceRequest request )
49+ protected override async Task < HttpResponseMessage > ForwardAsync ( HttpRequestMessage request , LlmRequestContext ctx )
4050 {
41- _records . Enqueue ( new InterceptedRequest ( request . Url , request . SessionId ) ) ;
42-
43- if ( IsInferenceUrl ( request . Url ) )
44- {
45- await HandleInferenceAsync ( request ) . ConfigureAwait ( false ) ;
46- }
47- else
48- {
49- await HandleNonInferenceModelTrafficAsync ( request ) . ConfigureAwait ( false ) ;
50- }
51+ var url = request . RequestUri ! . ToString ( ) ;
52+ _records . Enqueue ( new InterceptedRequest ( url , ctx . SessionId ) ) ;
53+
54+ var bodyText = request . Content is null
55+ ? string . Empty
56+ #if NET8_0_OR_GREATER
57+ : await request . Content . ReadAsStringAsync ( ctx . CancellationToken ) . ConfigureAwait ( false ) ;
58+ #else
59+ : await request . Content . ReadAsStringAsync ( ) . ConfigureAwait ( false ) ;
60+ #endif
61+
62+ return IsInferenceUrl ( url )
63+ ? BuildInferenceResponse ( url , bodyText )
64+ : BuildNonInferenceResponse ( url ) ;
5165 }
5266
5367 internal static bool IsInferenceUrl ( string url )
@@ -59,116 +73,67 @@ internal static bool IsInferenceUrl(string url)
5973 || u . EndsWith ( "/messages" , StringComparison . Ordinal ) ;
6074 }
6175
62- private static async Task < string > DrainRequestAsync ( LlmInferenceRequest req )
76+ /// <summary>
77+ /// Synthesizes a well-formed inference response so the agent turn completes.
78+ /// The runtime selects <c>/responses</c> for both the CAPI and BYOK sessions
79+ /// here; <c>/chat/completions</c> is handled too for robustness.
80+ /// </summary>
81+ private static HttpResponseMessage BuildInferenceResponse ( string url , string bodyText )
6382 {
64- using var buffer = new MemoryStream ( ) ;
65- await foreach ( var chunk in req . RequestBody . ConfigureAwait ( false ) )
83+ var wantsStream = WantsStreamRegex . IsMatch ( bodyText ) ;
84+ var u = url . ToLowerInvariant ( ) ;
85+
86+ if ( u . Contains ( "/responses" , StringComparison . Ordinal ) )
6687 {
67- if ( chunk . Length > 0 )
68- {
69- buffer . Write ( chunk . ToArray ( ) , 0 , chunk . Length ) ;
70- }
88+ return wantsStream
89+ ? Sse ( string . Concat ( ResponsesStreamEvents ) )
90+ : Json ( BufferedResponseJson ) ;
7191 }
7292
73- return Encoding . UTF8 . GetString ( buffer . ToArray ( ) ) ;
74- }
75-
76- private static async Task RespondBufferedAsync ( LlmInferenceRequest req , int status , string contentType , string body )
77- {
78- await DrainRequestAsync ( req ) . ConfigureAwait ( false ) ;
79- await req . ResponseBody . StartAsync ( new LlmInferenceResponseInit
80- {
81- Status = status ,
82- Headers = Headers ( contentType ) ,
83- } ) . ConfigureAwait ( false ) ;
84- if ( body . Length > 0 )
93+ if ( u . Contains ( "/chat/completions" , StringComparison . Ordinal ) && wantsStream )
8594 {
86- await req . ResponseBody . WriteAsync ( body ) . ConfigureAwait ( false ) ;
95+ return Sse ( string . Concat ( ChatCompletionStreamEvents ) ) ;
8796 }
8897
89- await req . ResponseBody . EndAsync ( ) . ConfigureAwait ( false ) ;
98+ // /chat/completions non-streaming (and any other inference url) — buffered JSON.
99+ return Json ( BufferedChatCompletionJson ) ;
90100 }
91101
92102 /// <summary>
93103 /// Serves the non-inference model-layer GETs/POSTs the runtime issues
94104 /// (catalog, model session, policy). These flow through the same callback
95105 /// but carry no session id (they happen outside an agent turn).
96106 /// </summary>
97- private static async Task HandleNonInferenceModelTrafficAsync ( LlmInferenceRequest req )
107+ private static HttpResponseMessage BuildNonInferenceResponse ( string url )
98108 {
99- var url = req . Url . ToLowerInvariant ( ) ;
100- if ( url . EndsWith ( "/models" , StringComparison . Ordinal ) )
109+ var u = url . ToLowerInvariant ( ) ;
110+ if ( u . EndsWith ( "/models" , StringComparison . Ordinal ) )
101111 {
102- await RespondBufferedAsync ( req , 200 , "application/json" , ModelCatalogJson ) . ConfigureAwait ( false ) ;
103- return ;
112+ return Json ( ModelCatalogJson ) ;
104113 }
105114
106- if ( url . Contains ( "/models/session" , StringComparison . Ordinal ) )
115+ if ( u . Contains ( "/models/session" , StringComparison . Ordinal ) )
107116 {
108- await RespondBufferedAsync ( req , 200 , "application/json" , "{}" ) . ConfigureAwait ( false ) ;
109- return ;
117+ return Json ( "{}" ) ;
110118 }
111119
112- if ( url . Contains ( "/policy" , StringComparison . Ordinal ) )
120+ if ( u . Contains ( "/policy" , StringComparison . Ordinal ) )
113121 {
114- await RespondBufferedAsync ( req , 200 , "application/json" , "{\" state\" :\" enabled\" }" ) . ConfigureAwait ( false ) ;
115- return ;
122+ return Json ( "{\" state\" :\" enabled\" }" ) ;
116123 }
117124
118- await RespondBufferedAsync ( req , 200 , "application/json" , " {}") . ConfigureAwait ( false ) ;
125+ return Json ( " {}") ;
119126 }
120127
121- /// <summary>
122- /// Synthesizes a well-formed inference response so the agent turn completes.
123- /// The runtime selects <c>/responses</c> for both the CAPI and BYOK sessions
124- /// here; <c>/chat/completions</c> is handled too for robustness.
125- /// </summary>
126- private static async Task HandleInferenceAsync ( LlmInferenceRequest req )
128+ private static HttpResponseMessage Json ( string body ) => new ( HttpStatusCode . OK )
127129 {
128- var bodyText = await DrainRequestAsync ( req ) . ConfigureAwait ( false ) ;
129- var wantsStream = WantsStreamRegex . IsMatch ( bodyText ) ;
130- var url = req . Url . ToLowerInvariant ( ) ;
131-
132- if ( url . Contains ( "/responses" , StringComparison . Ordinal ) )
133- {
134- if ( ! wantsStream )
135- {
136- await req . ResponseBody . StartAsync ( new LlmInferenceResponseInit { Status = 200 , Headers = Headers ( "application/json" ) } ) . ConfigureAwait ( false ) ;
137- await req . ResponseBody . WriteAsync ( BufferedResponseJson ) . ConfigureAwait ( false ) ;
138- await req . ResponseBody . EndAsync ( ) . ConfigureAwait ( false ) ;
139- return ;
140- }
141-
142- await req . ResponseBody . StartAsync ( new LlmInferenceResponseInit { Status = 200 , Headers = Headers ( "text/event-stream" ) } ) . ConfigureAwait ( false ) ;
143- foreach ( var sseEvent in ResponsesStreamEvents )
144- {
145- await req . ResponseBody . WriteAsync ( sseEvent ) . ConfigureAwait ( false ) ;
146- }
147-
148- await req . ResponseBody . EndAsync ( ) . ConfigureAwait ( false ) ;
149- return ;
150- }
151-
152- if ( url . Contains ( "/chat/completions" , StringComparison . Ordinal ) && wantsStream )
153- {
154- await req . ResponseBody . StartAsync ( new LlmInferenceResponseInit { Status = 200 , Headers = Headers ( "text/event-stream" ) } ) . ConfigureAwait ( false ) ;
155- foreach ( var sseEvent in ChatCompletionStreamEvents )
156- {
157- await req . ResponseBody . WriteAsync ( sseEvent ) . ConfigureAwait ( false ) ;
158- }
159-
160- await req . ResponseBody . EndAsync ( ) . ConfigureAwait ( false ) ;
161- return ;
162- }
163-
164- // /chat/completions non-streaming — buffered JSON.
165- await req . ResponseBody . StartAsync ( new LlmInferenceResponseInit { Status = 200 , Headers = Headers ( "application/json" ) } ) . ConfigureAwait ( false ) ;
166- await req . ResponseBody . WriteAsync ( BufferedChatCompletionJson ) . ConfigureAwait ( false ) ;
167- await req . ResponseBody . EndAsync ( ) . ConfigureAwait ( false ) ;
168- }
130+ Content = new StringContent ( body , Encoding . UTF8 , "application/json" ) ,
131+ } ;
169132
170- private static Dictionary < string , IReadOnlyList < string > > Headers ( string contentType ) =>
171- new ( ) { [ "content-type" ] = [ contentType ] } ;
133+ private static HttpResponseMessage Sse ( string body ) => new ( HttpStatusCode . OK )
134+ {
135+ Content = new StringContent ( body , Encoding . UTF8 , "text/event-stream" ) ,
136+ } ;
172137
173138 private static readonly string [ ] ResponsesStreamEvents =
174139 [
0 commit comments