Skip to content

Commit 2eb2754

Browse files
Collapse LLM inference callback public API to LlmRequestHandler
Hide the redundant low-level provider interface and adapter from the public surface in both SDKs; the sole public extension point is now the LlmRequestHandler base class. Replace the LlmInferenceConfig provider factory with a direct handler instance (the provider is client-global, constructed once with no args). .NET: ILlmInferenceProvider + the LlmInferenceRequest/ResponseInit/ResponseSink DTOs become internal; LlmRequestHandler implements the interface explicitly so OnLlmRequestAsync leaves its public surface. LlmInferenceConfig.Handler replaces the Func<LlmRequestHandler> factory. TS: stop exporting LlmInferenceProvider and createLlmInferenceAdapter from index.ts; LlmInferenceConfig.handler replaces createLlmInferenceProvider. The request/sink DTOs stay exported as onLlmRequest's contract (TS lacks explicit interface implementation). E2E providers become LlmRequestHandler subclasses overriding onLlmRequest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 65df1a1 commit 2eb2754

20 files changed

Lines changed: 150 additions & 192 deletions

dotnet/src/Client.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,18 +1692,15 @@ await Rpc.SessionFs.SetProviderAsync(
16921692
/// </summary>
16931693
private ClientGlobalApiHandlers? BuildClientGlobalApis()
16941694
{
1695-
var factory = _options.LlmInference?.CreateLlmInferenceProvider;
1696-
if (factory is null)
1695+
var handler = _options.LlmInference?.Handler;
1696+
if (handler is null)
16971697
{
16981698
return null;
16991699
}
17001700

1701-
var provider = factory()
1702-
?? throw new InvalidOperationException("LlmInferenceConfig.CreateLlmInferenceProvider returned null.");
1703-
17041701
return new ClientGlobalApiHandlers
17051702
{
1706-
LlmInference = new LlmInferenceAdapter(provider, () => _serverRpc),
1703+
LlmInference = new LlmInferenceAdapter(handler, () => _serverRpc),
17071704
};
17081705
}
17091706

dotnet/src/LlmInferenceProvider.cs

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ public enum LlmInferenceTransport
4242
/// (no provider type, endpoint kind, or wire API); consumers that need that
4343
/// information derive it from the URL / headers themselves.
4444
/// </remarks>
45-
[Experimental(Diagnostics.Experimental)]
46-
public sealed class LlmInferenceRequest
45+
internal sealed class LlmInferenceRequest
4746
{
4847
/// <summary>Opaque runtime-minted id, stable across the request lifecycle.</summary>
4948
public required string RequestId { get; init; }
@@ -100,8 +99,7 @@ public sealed class LlmInferenceRequest
10099
}
101100

102101
/// <summary>Response head passed to <see cref="LlmInferenceResponseSink.StartAsync"/>.</summary>
103-
[Experimental(Diagnostics.Experimental)]
104-
public sealed class LlmInferenceResponseInit
102+
internal sealed class LlmInferenceResponseInit
105103
{
106104
/// <summary>HTTP status code (101 acknowledges a WebSocket upgrade).</summary>
107105
public int Status { get; init; }
@@ -119,8 +117,7 @@ public sealed class LlmInferenceResponseInit
119117
/// exactly one of <see cref="EndAsync"/> or <see cref="ErrorAsync"/>. Calling
120118
/// out of order throws.
121119
/// </summary>
122-
[Experimental(Diagnostics.Experimental)]
123-
public abstract class LlmInferenceResponseSink
120+
internal abstract class LlmInferenceResponseSink
124121
{
125122
/// <summary>Sends the response head (status + headers) back to the runtime.</summary>
126123
public abstract Task StartAsync(LlmInferenceResponseInit init);
@@ -139,24 +136,23 @@ public abstract class LlmInferenceResponseSink
139136
}
140137

141138
/// <summary>
142-
/// Implemented by SDK consumers to service the LLM inference requests the
143-
/// runtime would otherwise issue itself. The same callback handles both
144-
/// buffered and streaming responses — the consumer just calls
139+
/// Internal seam implemented by <see cref="LlmRequestHandler"/> and consumed by
140+
/// <see cref="LlmInferenceAdapter"/>. The single callback handles both buffered
141+
/// and streaming responses — the implementer calls
145142
/// <see cref="LlmInferenceResponseSink.WriteAsync(ReadOnlyMemory{byte})"/> zero
146143
/// or more times before <see cref="LlmInferenceResponseSink.EndAsync"/>.
147144
/// </summary>
148145
/// <remarks>
149-
/// Prefer subclassing <see cref="LlmRequestHandler"/> for a transparent
150-
/// pass-through starting point; implement this interface directly only when you
151-
/// need full control over the raw byte streams.
146+
/// Not part of the public API: consumers subclass <see cref="LlmRequestHandler"/>
147+
/// rather than implementing this directly. It exists so the adapter can drive any
148+
/// handler through one uniform entry point.
152149
/// </remarks>
153-
[Experimental(Diagnostics.Experimental)]
154-
public interface ILlmInferenceProvider
150+
internal interface ILlmInferenceProvider
155151
{
156152
/// <summary>
157-
/// Invoked by the runtime once per outbound LLM request the consumer has
158-
/// opted to handle. The consumer is responsible for eventually calling
159-
/// either <see cref="LlmInferenceResponseSink.EndAsync"/> or
153+
/// Invoked by the adapter once per outbound LLM request. The implementer is
154+
/// responsible for eventually calling either
155+
/// <see cref="LlmInferenceResponseSink.EndAsync"/> or
160156
/// <see cref="LlmInferenceResponseSink.ErrorAsync"/>; failing to do so leaks
161157
/// runtime state. Throwing surfaces a transport-level failure to the runtime
162158
/// (equivalent to <c>ResponseBody.ErrorAsync(...)</c> when

dotnet/src/LlmRequestHandler.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,8 @@ public readonly struct LlmWebSocketMessage(ReadOnlyMemory<byte> data, bool isBin
5656

5757
/// <summary>
5858
/// Base class for SDK consumers who want to observe or mutate the LLM inference
59-
/// requests the runtime issues. Implements <see cref="ILlmInferenceProvider"/>,
60-
/// so an instance can be returned directly from
61-
/// <see cref="LlmInferenceConfig.CreateLlmInferenceProvider"/>.
59+
/// requests the runtime issues. An instance is returned directly from
60+
/// <see cref="LlmInferenceConfig.Handler"/>.
6261
/// </summary>
6362
/// <remarks>
6463
/// <para>
@@ -80,8 +79,7 @@ public readonly struct LlmWebSocketMessage(ReadOnlyMemory<byte> data, bool isBin
8079
/// — observe or mutate WebSocket messages in either direction.</item>
8180
/// </list>
8281
/// <para>
83-
/// The same subclass handles both transports —
84-
/// <see cref="OnLlmRequestAsync"/> dispatches on
82+
/// The same subclass handles both transports — dispatch keys on
8583
/// <see cref="LlmInferenceRequest.Transport"/>.
8684
/// </para>
8785
/// </remarks>
@@ -106,7 +104,7 @@ public class LlmRequestHandler : ILlmInferenceProvider
106104
};
107105

108106
/// <inheritdoc />
109-
public async Task OnLlmRequestAsync(LlmInferenceRequest request)
107+
async Task ILlmInferenceProvider.OnLlmRequestAsync(LlmInferenceRequest request)
110108
{
111109
ArgumentNullException.ThrowIfNull(request);
112110

dotnet/src/Types.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -495,13 +495,12 @@ public sealed class SessionFsConfig
495495
public sealed class LlmInferenceConfig
496496
{
497497
/// <summary>
498-
/// Factory invoked once when the client connects, producing the provider that
499-
/// will service every intercepted model-layer request for the lifetime of the
500-
/// connection. Return a <see cref="LlmRequestHandler"/> subclass for a
501-
/// transparent pass-through starting point, or any
502-
/// <see cref="ILlmInferenceProvider"/> for full control.
498+
/// Handler that services every intercepted model-layer request for the
499+
/// lifetime of the client connection. Subclass <see cref="LlmRequestHandler"/>
500+
/// and override its hooks to observe, mutate, or fully replace each
501+
/// request/response.
503502
/// </summary>
504-
public Func<ILlmInferenceProvider>? CreateLlmInferenceProvider { get; set; }
503+
public LlmRequestHandler? Handler { get; set; }
505504
}
506505

507506
/// <summary>

dotnet/test/E2E/LlmInferenceE2EProvider.cs

Lines changed: 64 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
*--------------------------------------------------------------------------------------------*/
44

55
using System.Collections.Concurrent;
6+
using System.Net;
7+
using System.Net.Http;
68
using System.Text;
79
using 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
[

dotnet/test/E2E/LlmInferenceSessionIdE2ETests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ private CopilotClient CreateClientWith(RecordingInferenceProvider provider) =>
2626
Connection = RuntimeConnection.ForStdio(),
2727
LlmInference = new LlmInferenceConfig
2828
{
29-
CreateLlmInferenceProvider = () => provider,
29+
Handler = provider,
3030
},
3131
});
3232

0 commit comments

Comments
 (0)