Last reviewed: November 14, 2026 (Asia/Shanghai); official references fetched November 13, 2026 UTC.
Direct answer
Stream a CometAPI chat response in C# by sending stream: true with a reused HttpClient, reading the response as UTF-8 text, and keeping Server-Sent Events (SSE) state across every network read. Accumulate data: lines until a blank line dispatches one event, parse each complete chat.completion.chunk as JSON, append only text deltas, and wait for both finish_reason: "stop" and data: [DONE] before returning the answer. A stream that ends after plausible text is incomplete.
The CometAPI Chat Completions reference
documents stream: true, chat.completion.chunk events, text in choices[0].delta.content, empty-choice usage events, and the final [DONE] marker. The WHATWG Server-sent events specification
defines UTF-8 decoding, line endings, comments, repeated data fields, and blank-line dispatch. Microsoft’s HttpClient guidance
and HttpCompletionOption reference
cover long-lived clients and the separate cancellation required after ResponseHeadersRead returns.
The line limit, output limit, event limit, and thirty-second deadline below are local safeguards. They do not establish a CometAPI quota, model latency, billing outcome, or process-wide memory cap.
Who this is for
Use this walkthrough for a .NET 8 or later console tool, worker, or server-side adapter that needs incremental text without treating transport chunks as protocol frames. It assumes the application owns a protected API key and a reviewed model identifier. Untrusted callers supply a bounded prompt; they do not choose the host, bearer token, or provider model.
The reader accepts one plain-text choice and rejects tool-call deltas, refusals, multiple choices, non-stop finishes, and malformed events. It is intentionally separate from a non-streaming JSON client. The C++ libcurl client walkthrough
shows ownership for a complete response in another runtime, while the Node.js byte-boundary SSE guide
provides a different implementation. This article focuses on .NET’s StreamReader, cancellation, and IAsyncEnumerable boundaries.
Key takeaways
- Keep one UTF-8 decoder and SSE accumulator alive for the entire response.
- A network read can split inside a multibyte character, line ending, JSON value, or
data:event. - Dispatch only on a blank line; comments do not produce chat chunks.
- Permit an empty
choicesusage event without indexingchoices[0]. - Keep the output and event counts bounded, and keep the cancellation token active during body reads.
- Return text only after a nonempty stopped choice and a complete
[DONE]event. - Treat retries, tool execution, and usage reconciliation as separate contracts.
Understand the three streaming boundaries
There are three independent boundaries: bytes become UTF-8 characters, characters become SSE events, and event data becomes a CometAPI chat chunk. A TCP or HTTP/2 read may stop at any of them. StreamReader.ReadLineAsync hides byte-to-character fragmentation, but it does not implement SSE field rules or the endpoint’s JSON contract, so the application still needs state around each line.
The WHATWG specification permits CRLF, LF, or CR line endings. A blank line dispatches the current event. Multiple data: lines are joined with a newline; a line beginning with : is a comment. An event with no data is not dispatched, and an incomplete event at EOF is discarded. The parser below follows those rules while imposing an application line and event-data ceiling.
CometAPI’s reference adds endpoint-specific behavior. A normal chunk has object: "chat.completion.chunk"; a text delta is in choices[0].delta.content; a finish chunk carries finish_reason; usage can arrive with an empty choices array; and data: [DONE] terminates the stream. Do not assume every event has a choice or that the last text delta proves completion.
Build a bounded SSE event reader
Create a .NET console project with dotnet new console --framework net8.0. Save the following as SseReader.cs. It uses a strict UTF-8 decoder, keeps event fields across reads, and never emits an unfinished event when the stream ends.
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
public sealed record SseEvent(string EventName, string Data);
public sealed class StreamFault : Exception
{
public string Code { get; }
public StreamFault(string code) : base(code) => Code = code;
}
public static class SseReader
{
public static async IAsyncEnumerable<SseEvent> ReadAsync(
Stream stream,
int maxLineChars = 65_536,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (stream is null) throw new ArgumentNullException(nameof(stream));
if (maxLineChars < 1) throw new ArgumentOutOfRangeException(nameof(maxLineChars));
using var reader = new StreamReader(
stream,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false,
throwOnInvalidBytes: true),
detectEncodingFromByteOrderMarks: false,
bufferSize: 8 * 1024,
leaveOpen: true);
var data = new StringBuilder();
var hasDataField = false;
string? eventName = null;
var dataChars = 0;
var firstLine = true;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
string? line;
try
{
line = await reader.ReadLineAsync(cancellationToken);
}
catch (DecoderFallbackException)
{
throw new StreamFault("invalid_utf8");
}
if (line is null) yield break; // The SSE spec discards an open event.
if (firstLine)
{
firstLine = false;
if (line.Length != 0 && line[0] == '\uFEFF')
line = line[1..];
}
if (line.Length > maxLineChars)
throw new StreamFault("line_too_large");
if (line.Length == 0)
{
if (hasDataField)
{
yield return new SseEvent(string.IsNullOrEmpty(eventName)
? "message" : eventName,
data.ToString());
}
data.Clear();
hasDataField = false;
eventName = null;
dataChars = 0;
continue;
}
if (line[0] == ':') continue;
var colon = line.IndexOf(':');
var field = colon < 0 ? line : line[..colon];
var value = colon < 0 ? string.Empty : line[(colon + 1)..];
if (value.StartsWith(' ')) value = value[1..];
switch (field)
{
case "event":
if (value.Length > maxLineChars)
throw new StreamFault("event_name_too_large");
eventName = value;
break;
case "data":
{
var separatorChars = hasDataField ? 1 : 0;
if (value.Length >
maxLineChars - dataChars - separatorChars)
throw new StreamFault("event_data_too_large");
if (hasDataField) data.Append('\n');
data.Append(value);
dataChars += separatorChars + value.Length;
hasDataField = true;
break;
}
// SSE id and retry fields are irrelevant to this POST response.
}
}
}
}
The StreamReader is created once per response. Its UTF8Encoding throws for malformed byte sequences instead of replacing bytes silently. ReadLineAsync may complete from an internal buffer or wait for more bytes; either way, the event lists survive until a blank line arrives. leaveOpen: true lets the caller own the response stream and dispose it with the HttpResponseMessage.
The reader does not dispatch a comment, an id, or an event with no data. It accepts the standard’s optional single space after a colon and joins repeated data: fields with a newline. dataChars counts both field values and the inserted newline separators, so empty repeated fields cannot bypass the event-data ceiling. The reader strips exactly one leading UTF-8 BOM before interpreting the first field; strict decoding remains enabled for every other byte. A production service should also bound total stream duration, delivered event count, and accumulated output, as the client below does.
ReadLineAsync materializes a line before the length check runs. The limit therefore rejects oversized input before JSON parsing, but it is not a process-wide preallocation guarantee. If an untrusted peer can send arbitrarily long unterminated lines, replace this helper with an incremental character reader that enforces the ceiling while it reads.
Validate chunks and retain only text
Save CometStreamClient.cs beside the reader. The client uses a reused HttpClient, a request-local bearer header, ResponseHeadersRead, and one linked cancellation budget. It reads [DONE] as a protocol event rather than trying to parse it as JSON.
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public sealed class CometStreamClient
{
private static readonly Uri Endpoint =
new("https://api.cometapi.com/v1/chat/completions");
private const int MaxPromptBytes = 8_000;
private const int MaxOutputChars = 65_536;
private const int MaxEvents = 10_000;
private static readonly TimeSpan RequestBudget = TimeSpan.FromSeconds(30);
private readonly HttpClient http;
public CometStreamClient(HttpClient httpClient) =>
http = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
public async Task<string> CompleteAsync(
string apiKey, string model, string prompt,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(apiKey) ||
apiKey.Contains('\r') || apiKey.Contains('\n') ||
string.IsNullOrWhiteSpace(model) || model.Length > 200)
throw new StreamFault("invalid_configuration");
if (string.IsNullOrWhiteSpace(prompt) ||
Encoding.UTF8.GetByteCount(prompt) > MaxPromptBytes)
throw new StreamFault("invalid_prompt");
var json = JsonSerializer.Serialize(new
{
model,
messages = new[] { new { role = "user", content = prompt } },
stream = true
});
using var budget = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
budget.CancelAfter(RequestBudget);
using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint);
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
request.Headers.Accept.Add(
new MediaTypeWithQualityHeaderValue("text/event-stream"));
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
HttpResponseMessage response;
try
{
response = await http.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, budget.Token);
}
catch (OperationCanceledException) when (budget.IsCancellationRequested)
{
throw new StreamFault("request_timeout");
}
catch (HttpRequestException)
{
throw new StreamFault("transport_failed");
}
using (response)
{
if (response.StatusCode != HttpStatusCode.OK)
throw new StreamFault("upstream_http_error");
var type = response.Content.Headers.ContentType?.MediaType;
if (!string.Equals(type, "text/event-stream",
StringComparison.OrdinalIgnoreCase))
throw new StreamFault("unexpected_media_type");
Stream body;
try
{
body = await response.Content.ReadAsStreamAsync(budget.Token);
}
catch (OperationCanceledException) when (budget.IsCancellationRequested)
{
throw new StreamFault("stream_timeout");
}
catch (HttpRequestException)
{
throw new StreamFault("stream_read_failed");
}
await using (body)
{
var text = new StringBuilder();
var finished = false;
var events = 0;
try
{
await foreach (var item in SseReader.ReadAsync(
body, cancellationToken: budget.Token))
{
if (++events > MaxEvents)
throw new StreamFault("event_limit");
if (item.EventName != "message")
throw new StreamFault("unexpected_event_type");
if (item.Data == "[DONE]")
{
if (!finished || !HasNonWhitespace(text))
throw new StreamFault("unaccepted_completion");
return text.ToString();
}
using var document = JsonDocument.Parse(item.Data,
new JsonDocumentOptions
{
MaxDepth = 32,
CommentHandling = JsonCommentHandling.Disallow
});
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
ReadString(root, "object") != "chat.completion.chunk")
throw new StreamFault("invalid_chunk");
var choices = root.GetProperty("choices");
if (choices.ValueKind != JsonValueKind.Array)
throw new StreamFault("invalid_choices");
if (choices.GetArrayLength() == 0) continue;
if (choices.GetArrayLength() != 1)
throw new StreamFault("unsupported_choices");
var choice = choices[0];
if (finished || ReadNumber(choice, "index") != 0)
throw new StreamFault("unexpected_choice_state");
var delta = choice.GetProperty("delta");
if (delta.ValueKind != JsonValueKind.Object)
throw new StreamFault("invalid_delta");
if (HasNonNull(delta, "tool_calls") ||
HasNonNull(delta, "function_call") ||
HasNonNull(delta, "refusal"))
throw new StreamFault("unsupported_delta");
if (delta.TryGetProperty("content", out var part) &&
part.ValueKind != JsonValueKind.Null)
{
if (part.ValueKind != JsonValueKind.String)
throw new StreamFault("invalid_delta");
var value = part.GetString()!;
if (value.Length > MaxOutputChars - text.Length)
throw new StreamFault("output_limit");
text.Append(value);
}
if (choice.TryGetProperty("finish_reason", out var finish) &&
finish.ValueKind != JsonValueKind.Null)
{
if (finish.ValueKind != JsonValueKind.String ||
finish.GetString() != "stop")
throw new StreamFault("unaccepted_finish");
finished = true;
}
}
}
catch (JsonException)
{
throw new StreamFault("invalid_chunk_json");
}
catch (KeyNotFoundException)
{
throw new StreamFault("invalid_chunk");
}
catch (InvalidOperationException)
{
throw new StreamFault("invalid_chunk");
}
catch (IOException)
{
throw new StreamFault("stream_read_failed");
}
catch (OperationCanceledException) when (budget.IsCancellationRequested)
{
throw new StreamFault("stream_timeout");
}
throw new StreamFault("missing_done");
}
}
}
private static string ReadString(JsonElement value, string property)
{
if (!value.TryGetProperty(property, out var item) ||
item.ValueKind != JsonValueKind.String)
throw new StreamFault("invalid_chunk");
return item.GetString()!;
}
private static int ReadNumber(JsonElement value, string property)
{
if (!value.TryGetProperty(property, out var item) ||
!item.TryGetInt32(out var number))
throw new StreamFault("invalid_choice");
return number;
}
private static bool HasNonNull(JsonElement value, string property) =>
value.TryGetProperty(property, out var item) &&
item.ValueKind != JsonValueKind.Null;
private static bool HasNonWhitespace(StringBuilder value)
{
for (var index = 0; index < value.Length; index++)
if (!char.IsWhiteSpace(value[index])) return true;
return false;
}
}
The CometAPI reference says stream_options.include_usage: true requests usage in a final chunk before [DONE]; the example leaves that option out so the completion rule remains focused on text. If accounting matters, validate the usage object separately and reconcile it with the provider’s account evidence. An empty choices usage event is still tolerated before the terminal marker.
The code rejects a choice after a finish chunk, but it still accepts an empty-choice usage event before [DONE]. StringBuilder enforces a character ceiling, which is distinct from the byte and UTF-8 limits on the incoming stream. If the product displays partial text while reading, label it provisional and do not persist it as a completed answer until the terminal marker passes.
Configure the lifetime and run one request
The client receives an application-owned HttpClient; create it once through dependency injection or a process-level owner. Microsoft recommends reusing instances for their lifecycle and connection resources. Do not mutate shared DefaultRequestHeaders for each call. The request-local Authorization header keeps concurrent calls isolated.
For a console smoke test, keep the credential and model in protected process configuration:
using System.Net.Http;
using var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseCookies = false
};
using var http = new HttpClient(handler)
{
// The linked token in CompleteAsync owns the per-call budget.
Timeout = Timeout.InfiniteTimeSpan
};
var key = Environment.GetEnvironmentVariable("COMETAPI_KEY");
var model = Environment.GetEnvironmentVariable("COMETAPI_MODEL");
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(model))
throw new InvalidOperationException("missing_configuration");
var client = new CometStreamClient(http);
var answer = await client.CompleteAsync(
key, model, "Explain idempotency in one short sentence.");
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(
new { text_present = !string.IsNullOrEmpty(answer) }));
This call can incur usage. Run the fixture and byte-boundary tests first, then make one authorized staging request with a harmless prompt. Log only the selected model, HTTP status, elapsed time, event count, output length, and fixed outcome code. Never print the key, full prompt, raw SSE event, or generated text into a shared diagnostic bundle.
Test every byte boundary without credentials
Build a fake HttpMessageHandler that captures the outgoing request and returns an HttpResponseMessage with Content-Type: text/event-stream. Its content stream should return the same fixture in controlled chunks. A fixture can contain a comment, CRLF and LF lines, multilingual text, a finish chunk, an empty-choice usage event, and [DONE]:
: heartbeat\r\n\r\ndata: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你好 🌍"},"finish_reason":null}]}\r\n\r\ndata: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\r\n\r\ndata: {"object":"chat.completion.chunk","choices":[],"usage":{"total_tokens":9}}\r\n\r\ndata: [DONE]\r\n\r\n
Encode that fixture as UTF-8 and run it through the same SseReader and chunk validator for every two-part split position, including positions inside the leading BOM, 你, the emoji, CRLF pairs, JSON strings, and blank lines. Add a one-byte-at-a-time stream to expose accidental assumptions about read size. The accepted output must always be 你好 🌍, and the reader must not return before [DONE].
The negative matrix should include:
- EOF after a valid text chunk but before
finish_reason: "stop". - A finish chunk without
[DONE], and a[DONE]event without a stopped, nonblank result. - An empty-choice usage event that appears before completion, which must be tolerated.
- A pre-dispatch event containing more than 65,536 empty
data:fields, which must fail the joined event-data limit before the blank line. - Malformed JSON, a wrong object marker, two choices, a nonzero index, and a non-object delta.
- Refusal,
tool_calls,function_call, a non-string content value, whitespace-only content, and a non-stopfinish. - Invalid UTF-8, an overlong line, overlong event data, output beyond the ceiling, and too many events.
- Delayed headers and a body that stalls after headers, both of which must honor cancellation.
Run dotnet test with a synthetic key and fixture model. These checks establish local framing, parser, limit, and cancellation behavior. They do not establish account access, model availability, billing, TLS reachability, or provider uptime. No authenticated request is needed for the parser suite.
Contract details to verify
| Boundary | Accepted behavior | Rejection to exercise |
|---|---|---|
| HTTP response | Status 200 and text/event-stream | JSON, HTML, redirect, or non-success status |
| UTF-8 | Characters survive arbitrary byte splits | Invalid bytes or incomplete encoding |
| SSE framing | Comments ignored; repeated data joined; blank line dispatches | EOF with an open event or an oversized line |
| Chat chunks | One index-zero choice or an empty-choice usage event | Extra choices, wrong envelope, or unexpected index |
| Completion | Nonblank text, stop, then [DONE] | Length finish, missing stop, or missing marker |
| Resources | Output, event, line, and lifetime limits | Stalled body, oversized output, or parser growth |
| Diagnostics | Fixed codes with no content or credential | Raw event, prompt, or authorization in logs |
The split sweep proves the selected fixture is independent of a two-part read boundary. It does not prove every multipart partition, timing schedule, proxy behavior, or server implementation. Keep the fixture small, add randomized partitions for important regressions, and retain a separate live observation if the application depends on usage events.
Failure modes
The parser assumes one network read equals one event. Transport reads are arbitrary. Keep UTF-8 decoding, line accumulation, and event dispatch state alive across reads.
Text is accepted before the terminal marker. A provider can close after sending plausible deltas. Require both the stopped finish and [DONE] before returning a completed answer.
Every event indexes choices[0]. Usage events can have an empty choices array. Check the array length and process usage before indexing.
HttpClient.Timeout is assumed to cover stream consumption. With ResponseHeadersRead, the documented timeout scope ends when headers arrive. Pass the linked token to the stream and every ReadLineAsync call.
A parser exception leaks generated content. Map malformed events to fixed codes and keep raw lines, prompts, keys, and partial output out of ordinary logs.
A timeout starts an automatic paid retry. A local cancellation does not prove that the provider stopped. Add an attempt identity, reconciliation evidence, total retry budget, and replay policy before retrying.
FAQ
Does StreamReader handle every SSE rule for me?
It handles character decoding and line separation. The application still needs the SSE field rules, blank-line dispatch, repeated-data joining, event and output limits, and the CometAPI chunk contract.
Why wait for [DONE] after finish_reason: "stop"?
The CometAPI reference shows that usage can arrive after the finish chunk and before [DONE]. Waiting for the terminal marker preserves that event and rejects an early EOF as incomplete.
Can this method execute tool calls?
No. It rejects tool-call and function-call deltas. Tool execution needs validated arguments, authorization, side-effect idempotency, a tool result message, and a different completion state machine.
Does cancellation stop upstream generation or billing?
The fixtures prove local cancellation and stream closure only. They provide no upstream billing guarantee. Reconcile usage and retry decisions using the account and application evidence available to your deployment.
Sources checked
Four distinct primary public pages returned HTTP 200 on November 13, 2026 UTC:
- CometAPI: Create a chat completion
— streaming request mode, chunk envelope, text deltas, empty-choice usage event, finish reasons, and
[DONE]marker. - WHATWG: Server-sent events — UTF-8, CRLF/LF/CR line handling, comments, data-field joining, blank-line dispatch, and incomplete-event behavior.
- Microsoft: Make HTTP requests with the HttpClient class — reusable client lifetimes, asynchronous sends, and response content handling.
- Microsoft: HttpCompletionOption Enum
—
ResponseHeadersReadsemantics and the need to apply timeout and cancellation while reading content.
These maintained references support an evergreen streaming integration task. The line, output, event, and deadline values, the strict UTF-8 choice, and the text-only acceptance policy are local recommendations; revisit them when the application or response contract changes.
Reader next step
Implement SseReader behind a fake response first. Run the every-byte-split fixture and negative matrix, then inspect the captured request for the fixed HTTPS URI, bearer header, JSON body, and stream: true. Make one authorized staging request through the same reader and record only sanitized status, event count, output length, usage presence, and terminal acceptance. Add tools, retries, or alternate event types as separate reviewed contracts.