/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
using GitHub.Copilot.Rpc;
using System.Buffers;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Net.WebSockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Channels;
namespace GitHub.Copilot;
///
/// Transport the runtime would otherwise use to issue an intercepted
/// model-layer request.
///
[Experimental(Diagnostics.Experimental)]
public enum CopilotRequestTransport
{
///
/// Plain HTTP or a streamed SSE response. Each body chunk is an opaque
/// byte range.
///
Http,
///
/// Full-duplex WebSocket channel. Each request-body chunk is one inbound
/// WebSocket message and each response-body write is one outbound message.
///
WebSocket,
}
///
/// Per-request context handed to every hook.
/// Exposes the routing and cancellation details of a single intercepted request
/// so overrides can observe or rewrite it.
///
[Experimental(Diagnostics.Experimental)]
public sealed class CopilotRequestContext
{
///
/// Creates an instance of by copying the values from another instance.
///
/// A instance to copy values from.
public CopilotRequestContext(CopilotRequestContext original)
: this(original.RequestId, original.Url, original.Headers)
{
SessionId = original.SessionId;
Transport = original.Transport;
CancellationToken = original.CancellationToken;
WebSocketResponse = original.WebSocketResponse;
}
internal CopilotRequestContext(string requestId, string url, IReadOnlyDictionary> headers)
{
RequestId = requestId;
Url = url;
Headers = headers;
}
/// Opaque runtime-minted id, stable across the request lifecycle.
public string RequestId { get; init; }
/// Runtime session id that triggered the request, if any.
public string? SessionId { get; init; }
/// Transport the runtime would otherwise use.
public CopilotRequestTransport Transport { get; init; }
/// Request URL.
public string Url { get; init; }
/// Request headers.
public IReadOnlyDictionary> Headers { get; init; }
///
/// Cancelled when the runtime aborts this in-flight request. Subclasses that
/// issue their own I/O should pass this through so the upstream call is torn
/// down too.
///
public CancellationToken CancellationToken { get; init; }
internal LlmWebSocketResponseBridge? WebSocketResponse { get; set; }
}
/// A single WebSocket message exchanged through a hook.
[Experimental(Diagnostics.Experimental)]
public readonly struct CopilotWebSocketMessage(ReadOnlyMemory data, bool isBinary)
{
/// The message payload bytes.
public ReadOnlyMemory Data { get; } = data;
/// True for a binary frame; false for a UTF-8 text frame.
public bool IsBinary { get; } = isBinary;
/// Decodes the payload as UTF-8 text.
public string GetText() => Encoding.UTF8.GetString(Data.Span);
/// Creates a text message from a UTF-8 string.
public static CopilotWebSocketMessage FromText(string text) => new(Encoding.UTF8.GetBytes(text), isBinary: false);
}
///
/// Terminal status for a callback-owned WebSocket connection.
///
[Experimental(Diagnostics.Experimental)]
public sealed class CopilotWebSocketCloseStatus
{
/// The close description, if any.
public string? Description { get; init; }
///
/// Optional error code surfaced to the runtime when the close is a failure
/// rather than a clean end-of-stream.
///
public string? ErrorCode { get; init; }
/// The error that terminated the connection, if any.
public Exception? Error { get; init; }
/// Shared normal-closure instance.
public static CopilotWebSocketCloseStatus NormalClosure { get; } = new();
}
///
/// Lower-level WebSocket handler with no upstream connection. This is the
/// abstract base shared by all WebSocket handlers; it does not open or forward
/// to any upstream server on its own. Subclass it directly only to service a
/// fully synthetic connection yourself. For the common case of mutating and
/// forwarding traffic to the real upstream, subclass
/// instead, which connects upstream and
/// forwards by default.
///
[Experimental(Diagnostics.Experimental)]
public abstract class CopilotWebSocketHandler : IAsyncDisposable
{
private readonly TaskCompletionSource _completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _closed;
private bool _suppressCloseOnDispose;
/// Request context for this WebSocket connection.
protected CopilotRequestContext Context { get; }
internal Task Completion => _completion.Task;
///
/// Initializes a per-connection handler for the supplied request context.
///
protected CopilotWebSocketHandler(CopilotRequestContext context)
{
Context = context;
_ = context.WebSocketResponse ?? throw new InvalidOperationException("WebSocket response bridge is not attached.");
}
///
/// Send a message from the runtime to the upstream connection.
///
public abstract Task SendRequestMessageAsync(CopilotWebSocketMessage message);
///
/// Send a message from the upstream connection back to the runtime.
/// Override to mutate or duplicate messages; call base to emit.
///
public virtual Task SendResponseMessageAsync(CopilotWebSocketMessage message) =>
Context.WebSocketResponse!.WriteAsync(message);
///
/// Close the connection and finalise the runtime-facing response.
///
public virtual async Task CloseAsync(CopilotWebSocketCloseStatus status)
{
if (Interlocked.Exchange(ref _closed, 1) != 0)
{
return;
}
if (status.Error is not null)
{
await Context.WebSocketResponse!
.ErrorAsync(status.Description ?? status.Error.Message, status.ErrorCode)
.ConfigureAwait(false);
}
else
{
await Context.WebSocketResponse!.EndAsync().ConfigureAwait(false);
}
_completion.TrySetResult(status);
}
internal void SuppressCloseOnDispose() => _suppressCloseOnDispose = true;
internal virtual Task OpenAsync() => Task.CompletedTask;
///
public virtual async ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
if (!_suppressCloseOnDispose && Volatile.Read(ref _closed) == 0)
{
await CloseAsync(CopilotWebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
}
}
}
///
/// WebSocket handler that connects to the real upstream and forwards traffic by
/// default. This is the type returned by the default
/// . Override nothing to
/// get full pass-through. To mutate traffic, subclass this type and override a
/// send method, then call the base implementation to keep forwarding upstream.
/// (Subclassing instead would drop
/// forwarding entirely.)
///
[Experimental(Diagnostics.Experimental)]
public class CopilotWebSocketForwarder : CopilotWebSocketHandler
{
private WebSocket? _upstream;
private CancellationTokenSource? _pumpCts;
private Task? _responsePump;
///
/// Initializes a forwarding handler that will open the upstream socket on
/// demand using the supplied URL/headers from .
///
public CopilotWebSocketForwarder(CopilotRequestContext context)
: base(context)
{
}
///
/// Opens the upstream socket and starts the built-in response pump.
///
internal override async Task OpenAsync()
{
if (_upstream is not null)
{
return;
}
var socket = new ClientWebSocket();
foreach (var (name, values) in Context.Headers)
{
if (LlmInferenceHeaders.Forbidden.Contains(name))
{
continue;
}
try
{
socket.Options.SetRequestHeader(name, string.Join(", ", values));
}
catch
{
// Some headers are managed by the handshake; ignore rejections.
}
}
await socket.ConnectAsync(ToWebSocketUri(Context.Url), Context.CancellationToken).ConfigureAwait(false);
_upstream = socket;
_pumpCts = CancellationTokenSource.CreateLinkedTokenSource(Context.CancellationToken);
// Start the pump without a cancellation token on Task.Run itself: if the
// linked token is already cancelled, we still want PumpResponsesAsync to
// run so its cleanup (closing the upstream and finalising the response)
// executes rather than the task being cancelled before it ever starts.
_responsePump = Task.Run(() => PumpResponsesAsync(_pumpCts.Token));
}
///
/// Sends a message from the runtime to the upstream connection. Subclasses may override to mutate messages.
///
/// The message to send.
/// A representing the asynchronous operation.
public override Task SendRequestMessageAsync(CopilotWebSocketMessage message)
{
if (_upstream?.State != WebSocketState.Open)
{
return Task.CompletedTask;
}
var type = message.IsBinary ? WebSocketMessageType.Binary : WebSocketMessageType.Text;
return _upstream.SendAsync(
message.Data,
type,
endOfMessage: true,
Context.CancellationToken).AsTask();
}
///
public override async Task CloseAsync(CopilotWebSocketCloseStatus status)
{
_pumpCts?.Cancel();
if (_upstream is not null)
{
await CloseWebSocketQuietlyAsync(_upstream).ConfigureAwait(false);
}
await base.CloseAsync(status).ConfigureAwait(false);
}
///
public override async ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
try
{
await base.DisposeAsync().ConfigureAwait(false);
}
finally
{
_pumpCts?.Cancel();
_pumpCts?.Dispose();
_upstream?.Dispose();
if (_responsePump is not null)
{
await ObserveQuietlyAsync(_responsePump).ConfigureAwait(false);
}
}
}
private async Task PumpResponsesAsync(CancellationToken cancellationToken)
{
if (_upstream is null)
{
return;
}
try
{
while (_upstream.State == WebSocketState.Open)
{
var message = await ReceiveMessageAsync(_upstream, cancellationToken).ConfigureAwait(false);
if (message is null)
{
break;
}
await SendResponseMessageAsync(message.Value).ConfigureAwait(false);
}
await CloseAsync(CopilotWebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
}
catch (OperationCanceledException) when (Context.CancellationToken.IsCancellationRequested)
{
// Runtime-side cancellation aborts the request pump; the outer
// handler rethrows that cancellation rather than finalising here.
}
catch (Exception ex)
{
await CloseAsync(new CopilotWebSocketCloseStatus
{
Description = ex.Message,
Error = ex,
}).ConfigureAwait(false);
}
}
private static async Task ReceiveMessageAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = ArrayPool.Shared.Rent(16 * 1024);
try
{
using var assembled = new MemoryStream();
ValueWebSocketReceiveResult result;
do
{
try
{
result = await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (WebSocketException)
{
return null;
}
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
assembled.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
return new CopilotWebSocketMessage(assembled.ToArray(), result.MessageType == WebSocketMessageType.Binary);
}
finally
{
ArrayPool.Shared.Return(buffer);
}
}
private static async Task CloseWebSocketQuietlyAsync(WebSocket socket)
{
try
{
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, statusDescription: null, CancellationToken.None).ConfigureAwait(false);
}
}
catch
{
// Best-effort; the socket may already be closed.
}
}
[SuppressMessage("Usage", "CA1031:Do not catch general exception types", Justification = "Best-effort teardown of the losing pump.")]
private static async Task ObserveQuietlyAsync(Task task)
{
try
{
await task.ConfigureAwait(false);
}
catch
{
// Best-effort teardown only.
}
}
private static Uri ToWebSocketUri(string url)
{
var builder = new UriBuilder(url);
if (builder.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))
{
builder.Scheme = "wss";
}
else if (builder.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase))
{
builder.Scheme = "ws";
}
return builder.Uri;
}
}
///
/// Base class for SDK consumers who want to observe or mutate the LLM inference
/// requests the runtime issues (for both CAPI and BYOK providers). Subclass and
/// override or .
///
[Experimental(Diagnostics.Experimental)]
public class CopilotRequestHandler
{
private static readonly HttpClient s_sharedHttpClient = new();
private readonly HttpClient _httpClient;
///
/// Initializes a new instance that issues upstream requests using a shared
/// process-wide .
///
public CopilotRequestHandler()
: this(null)
{
}
///
/// Initializes a new instance that issues upstream requests using the supplied
/// , or a shared process-wide instance when is .
///
/// The to use, or to use the shared instance.
public CopilotRequestHandler(HttpClient? httpClient)
{
_httpClient = httpClient ?? s_sharedHttpClient;
}
///
/// Issue the upstream HTTP request. Override to mutate the request before
/// calling base, mutate the returned response after, or replace the
/// call entirely.
///
protected virtual Task SendRequestAsync(HttpRequestMessage request, CopilotRequestContext ctx) =>
_httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx.CancellationToken);
///
/// Open the upstream WebSocket connection. Override to return a custom
/// or to construct a
/// against a rewritten URL.
///
protected virtual Task OpenWebSocketAsync(CopilotRequestContext ctx) =>
Task.FromResult(new CopilotWebSocketForwarder(ctx));
///
/// Entry point invoked by the adapter once per intercepted request. Routes to
/// the HTTP or WebSocket flow and drives the consumer's overridable hooks.
///
internal Task HandleAsync(LlmInferenceExchange exchange) =>
exchange.Context.Transport == CopilotRequestTransport.WebSocket
? HandleWebSocketAsync(exchange)
: HandleHttpAsync(exchange);
private async Task HandleHttpAsync(LlmInferenceExchange exchange)
{
using var request = await BuildHttpRequestAsync(exchange).ConfigureAwait(false);
using var response = await SendRequestAsync(request, exchange.Context).ConfigureAwait(false);
await StreamResponseAsync(response, exchange).ConfigureAwait(false);
}
private static async Task BuildHttpRequestAsync(LlmInferenceExchange exchange)
{
var method = new HttpMethod(exchange.Method);
var message = new HttpRequestMessage(method, exchange.Context.Url);
var hasBody = method != HttpMethod.Get && method != HttpMethod.Head;
var body = await DrainAsync(exchange.RequestBody).ConfigureAwait(false);
if (hasBody && body.Length > 0)
{
message.Content = new ByteArrayContent(body);
}
foreach (var (name, values) in exchange.Context.Headers)
{
if (LlmInferenceHeaders.Forbidden.Contains(name))
{
continue;
}
if (!message.Headers.TryAddWithoutValidation(name, values))
{
message.Content ??= new ByteArrayContent([]);
message.Content.Headers.TryAddWithoutValidation(name, values);
}
}
return message;
}
private static async Task StreamResponseAsync(HttpResponseMessage response, LlmInferenceExchange exchange)
{
await exchange.StartResponseAsync(
(int)response.StatusCode,
response.ReasonPhrase,
HeadersToMultiMap(response)).ConfigureAwait(false);
var ct = exchange.Context.CancellationToken;
using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var buffer = new byte[16 * 1024];
int read;
while ((read = await stream.ReadAsync(buffer.AsMemory(), ct).ConfigureAwait(false)) > 0)
{
await exchange.WriteResponseAsync(new ReadOnlyMemory(buffer, 0, read)).ConfigureAwait(false);
}
await exchange.EndResponseAsync().ConfigureAwait(false);
}
private async Task HandleWebSocketAsync(LlmInferenceExchange exchange)
{
var ctx = exchange.Context;
var bridge = new LlmWebSocketResponseBridge(exchange);
ctx.WebSocketResponse = bridge;
var handler = await OpenWebSocketAsync(ctx).ConfigureAwait(false);
try
{
await handler.OpenAsync().ConfigureAwait(false);
// The runtime blocks the WebSocket connect until it receives the
// 101 response head (the upgrade acknowledgement) and only then
// begins forwarding inbound messages as request-body chunks. Emit
// it eagerly here — waiting for the first upstream message would
// deadlock, since the upstream stays silent until it receives a
// request message the runtime won't send before the upgrade
// completes.
await bridge.StartAsync().ConfigureAwait(false);
var clientPump = Task.Run(async () =>
{
await foreach (var chunk in exchange.RequestBody.WithCancellation(ctx.CancellationToken).ConfigureAwait(false))
{
await handler.SendRequestMessageAsync(new CopilotWebSocketMessage(chunk, isBinary: false)).ConfigureAwait(false);
}
}, ctx.CancellationToken);
var first = await Task.WhenAny(clientPump, handler.Completion).ConfigureAwait(false);
if (first == clientPump)
{
if (clientPump.IsFaulted || clientPump.IsCanceled)
{
handler.SuppressCloseOnDispose();
await clientPump.ConfigureAwait(false);
}
await handler.CloseAsync(CopilotWebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
await handler.Completion.ConfigureAwait(false);
return;
}
var closeStatus = await handler.Completion.ConfigureAwait(false);
if (closeStatus.Error is not null)
{
throw closeStatus.Error;
}
}
finally
{
await handler.DisposeAsync().ConfigureAwait(false);
}
}
private static async Task DrainAsync(IAsyncEnumerable> stream)
{
using var buffer = new MemoryStream();
await foreach (var chunk in stream.ConfigureAwait(false))
{
if (chunk.Length > 0)
{
buffer.Write(chunk.Span);
}
}
return buffer.ToArray();
}
private static Dictionary> HeadersToMultiMap(HttpResponseMessage response)
{
var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (var header in response.Headers)
{
result[header.Key] = [.. header.Value];
}
if (response.Content is not null)
{
foreach (var header in response.Content.Headers)
{
result[header.Key] = [.. header.Value];
}
}
return result;
}
}
///
/// One intercepted request in flight. Carries the request context plus the body
/// byte stream the runtime feeds in via httpRequestChunk frames, and
/// emits the consumer's response straight back to the runtime through the
/// generated llmInference server API. Replaces the former
/// provider/sink/response-channel indirection with a single object the adapter
/// owns and the handler writes to.
///
internal sealed class LlmInferenceExchange
{
private readonly Func _getServerRpc;
private readonly Channel _body = Channel.CreateUnbounded(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
private bool _started;
private bool _finished;
private bool _cancelled;
internal LlmInferenceExchange(string requestId, Func getServerRpc)
{
RequestId = requestId;
_getServerRpc = getServerRpc;
}
internal string RequestId { get; }
internal string Method { get; set; } = "GET";
internal CopilotRequestContext Context { get; set; } = null!;
internal CancellationTokenSource Abort { get; } = new();
internal bool Started => _started;
internal bool Finished => _finished;
internal bool Cancelled => _cancelled;
// --- Request body feed (driven by the adapter as chunk frames arrive) ---
internal void PushChunk(byte[] data) => _body.Writer.TryWrite(new BodyItem { Chunk = data });
internal void PushEnd() => _body.Writer.TryWrite(new BodyItem { End = true });
internal void PushCancel(string? reason)
{
_cancelled = true;
Abort.Cancel();
_body.Writer.TryWrite(new BodyItem { Cancel = true, CancelReason = reason });
}
///
/// Request body bytes, yielded as they arrive. A cancel frame surfaces as an
/// so the consumer's upstream call
/// is torn down.
///
internal IAsyncEnumerable> RequestBody => ReadBodyAsync(Abort.Token);
private async IAsyncEnumerable> ReadBodyAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
while (await _body.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (_body.Reader.TryRead(out var item))
{
if (item.Cancel)
{
_body.Writer.TryComplete();
throw new OperationCanceledException(
item.CancelReason is null
? "Request cancelled by runtime"
: $"Request cancelled by runtime: {item.CancelReason}");
}
if (item.End)
{
_body.Writer.TryComplete();
yield break;
}
if (item.Chunk is { Length: > 0 })
{
yield return item.Chunk;
}
}
}
}
// --- Response emit (driven by the handler). Strict state machine: ---
// StartResponseAsync once -> zero or more WriteResponseAsync -> exactly one
// of EndResponseAsync / ErrorResponseAsync.
internal async Task StartResponseAsync(int status, string? statusText, IReadOnlyDictionary>? headers)
{
if (_started)
{
throw new InvalidOperationException("LLM inference response StartAsync() called twice.");
}
if (_finished)
{
throw new InvalidOperationException("LLM inference response already finished.");
}
_started = true;
await ServerRpc()
.LlmInference.HttpResponseStartAsync(RequestId, status, ToWireHeaders(headers), statusText)
.ConfigureAwait(false);
}
internal Task WriteResponseAsync(ReadOnlyMemory data) =>
WriteChunkAsync(Convert.ToBase64String(data.ToArray()), binary: true);
internal Task WriteResponseAsync(string text)
{
ArgumentNullException.ThrowIfNull(text);
return WriteChunkAsync(text, binary: false);
}
internal async Task EndResponseAsync()
{
if (_finished)
{
return;
}
_finished = true;
await ServerRpc().LlmInference.HttpResponseChunkAsync(RequestId, string.Empty, end: true).ConfigureAwait(false);
}
internal async Task ErrorResponseAsync(string message, string? code = null)
{
ArgumentNullException.ThrowIfNull(message);
if (_finished)
{
return;
}
_finished = true;
await ServerRpc()
.LlmInference.HttpResponseChunkAsync(
RequestId,
string.Empty,
end: true,
error: new LlmInferenceHttpResponseChunkError { Message = message, Code = code })
.ConfigureAwait(false);
}
private async Task WriteChunkAsync(string data, bool binary)
{
if (_cancelled)
{
throw new InvalidOperationException("LLM inference request was cancelled by the runtime.");
}
if (!_started)
{
throw new InvalidOperationException("LLM inference response WriteAsync() called before StartAsync().");
}
if (_finished)
{
throw new InvalidOperationException("LLM inference response WriteAsync() called after EndAsync()/ErrorAsync().");
}
await ServerRpc()
.LlmInference.HttpResponseChunkAsync(RequestId, data, binary: binary, end: false)
.ConfigureAwait(false);
}
private ServerRpc ServerRpc() =>
_getServerRpc() ?? throw new InvalidOperationException("LLM inference response used after RPC connection closed.");
private static Dictionary> ToWireHeaders(IReadOnlyDictionary>? headers)
{
var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
if (headers is null)
{
return result;
}
foreach (var (name, values) in headers)
{
result[name] = values as IList ?? [.. values];
}
return result;
}
private struct BodyItem
{
public byte[]? Chunk;
public bool End;
public bool Cancel;
public string? CancelReason;
}
}
///
/// Adapts the generated RPC entry points onto
/// a consumer's . Each httpRequestStart
/// allocates an and runs the handler in the
/// background; subsequent httpRequestChunk frames feed its body stream.
///
internal sealed class LlmInferenceAdapter(CopilotRequestHandler handler, Func getServerRpc) : ILlmInferenceHandler
{
private readonly CopilotRequestHandler _handler = handler ?? throw new ArgumentNullException(nameof(handler));
private readonly Func _getServerRpc = getServerRpc ?? throw new ArgumentNullException(nameof(getServerRpc));
private readonly ConcurrentDictionary _pending = new(StringComparer.Ordinal);
public Task HttpRequestStartAsync(LlmInferenceHttpRequestStartRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var transport = request.Transport == LlmInferenceHttpRequestStartTransport.Websocket
? CopilotRequestTransport.WebSocket
: CopilotRequestTransport.Http;
// The runtime dispatches httpRequestStart and httpRequestChunk frames
// concurrently, so body chunks (including the terminal end frame) can
// arrive before this start frame runs. GetOrAdd adopts any exchange a
// racing chunk already created — with its buffered body — instead of
// dropping those frames and hanging the body drain.
var exchange = _pending.GetOrAdd(request.RequestId, id => new LlmInferenceExchange(id, _getServerRpc));
exchange.Method = request.Method;
exchange.Context = new CopilotRequestContext(request.RequestId, request.Url, ToReadOnlyHeaders(request.Headers))
{
SessionId = request.SessionId,
Transport = transport,
CancellationToken = exchange.Abort.Token,
};
// Return from httpRequestStart immediately (after registering state) so
// the runtime's RPC reply is not gated on the consumer's I/O. The actual
// handler work runs asynchronously, exactly once per request.
_ = RunAsync(exchange);
return Task.FromResult(new LlmInferenceHttpRequestStartResult());
}
public Task HttpRequestChunkAsync(LlmInferenceHttpRequestChunkRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// A chunk may arrive before its matching httpRequestStart (frames are
// dispatched concurrently). GetOrAdd buffers the body into the
// exchange's channel so no chunk — in particular the terminal end
// frame — is ever lost; the start frame later adopts this same exchange.
var exchange = _pending.GetOrAdd(request.RequestId, id => new LlmInferenceExchange(id, _getServerRpc));
RouteChunk(exchange, request);
return Task.FromResult(new LlmInferenceHttpRequestChunkResult());
}
private async Task RunAsync(LlmInferenceExchange exchange)
{
try
{
await _handler.HandleAsync(exchange).ConfigureAwait(false);
if (!exchange.Finished)
{
await FinalizeAsync(exchange, 502, "LLM inference handler returned without finalising the response (call ResponseBody.EndAsync() or .ErrorAsync()).", code: null).ConfigureAwait(false);
}
}
catch (Exception ex)
{
if (exchange.Cancelled || exchange.Abort.IsCancellationRequested)
{
// The runtime already cancelled this request; the handler's throw
// is just the abort propagating out of its upstream call.
await FinalizeAsync(exchange, 499, "Request cancelled by runtime", code: "cancelled").ConfigureAwait(false);
return;
}
await FinalizeAsync(exchange, 502, ex.Message, code: null).ConfigureAwait(false);
}
finally
{
_pending.TryRemove(exchange.RequestId, out _);
}
}
private static async Task FinalizeAsync(LlmInferenceExchange exchange, int status, string message, string? code)
{
if (exchange.Finished)
{
return;
}
try
{
if (!exchange.Started)
{
await exchange.StartResponseAsync(status, statusText: null, headers: null).ConfigureAwait(false);
}
await exchange.ErrorResponseAsync(message, code).ConfigureAwait(false);
}
catch
{
// Best-effort — the connection may already be dead.
}
}
private static void RouteChunk(LlmInferenceExchange exchange, LlmInferenceHttpRequestChunkRequest chunk)
{
if (chunk.Cancel == true)
{
exchange.PushCancel(chunk.CancelReason);
return;
}
if (!string.IsNullOrEmpty(chunk.Data))
{
exchange.PushChunk(DecodeChunkData(chunk.Data, chunk.Binary == true));
}
if (chunk.End == true)
{
exchange.PushEnd();
}
}
private static byte[] DecodeChunkData(string data, bool binary) =>
binary ? Convert.FromBase64String(data) : Encoding.UTF8.GetBytes(data);
private static Dictionary> ToReadOnlyHeaders(IDictionary> headers)
{
var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, values) in headers)
{
result[name] = values as IReadOnlyList ?? [.. values];
}
return result;
}
}
///
/// Forwards upstream WebSocket messages back to the owning
/// . The 101 upgrade head is emitted eagerly
/// via (the runtime gates the connect on it);
/// thereafter writes are serialised so the head always precedes any body or
/// terminal frame.
///
internal sealed class LlmWebSocketResponseBridge(LlmInferenceExchange exchange)
{
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _started;
private bool _completed;
/// Emit the 101 upgrade head now, acknowledging the WebSocket connect.
internal Task StartAsync() => RunAsync(terminal: false, () => Task.CompletedTask);
internal Task WriteAsync(CopilotWebSocketMessage message) => RunAsync(terminal: false, () =>
message.IsBinary
? exchange.WriteResponseAsync(message.Data)
: exchange.WriteResponseAsync(message.GetText()));
internal Task EndAsync() => RunAsync(terminal: true, () => exchange.EndResponseAsync());
internal Task ErrorAsync(string message, string? code) =>
RunAsync(terminal: true, () => exchange.ErrorResponseAsync(message, code));
private async Task RunAsync(bool terminal, Func action)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
if (_completed)
{
return;
}
if (!_started)
{
_started = true;
await exchange.StartResponseAsync(101, statusText: null, headers: null).ConfigureAwait(false);
}
if (terminal)
{
_completed = true;
}
await action().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
}
internal static class LlmInferenceHeaders
{
// Computed/managed by the HTTP/WS stack; forwarding them verbatim either
// throws or corrupts the request.
internal static readonly HashSet Forbidden = new(StringComparer.OrdinalIgnoreCase)
{
"host",
"connection",
"content-length",
"transfer-encoding",
"keep-alive",
"upgrade",
"proxy-connection",
"te",
"trailer",
};
}