CookbookStream in C#

Stream in C#

stream_options.include_usage is Preview (not available yet). Until it ships, leave it out; the code below simply prints no usage line.

Print a reply as it is generated with HttpClient in .NET 8.

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
 
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
var request = new HttpRequestMessage(HttpMethod.Post, "https://app.syntic.ai/v1/chat/completions")
{
    Content = new StringContent("""
        {"model": "Amara S1.2", "stream": true, "stream_options": {"include_usage": true},
         "messages": [{"role": "user", "content": "Explain idempotency keys in three sentences."}]}
        """, Encoding.UTF8, "application/json"),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("SYNTIC_API_KEY"));
 
// ResponseHeadersRead: start reading before the body is complete.
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode) throw new Exception($"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");
 
using var reader = new StreamReader(await response.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is { } line)
{
    if (!line.StartsWith("data: ")) continue;
    var payload = line[6..];
    if (payload == "[DONE]") break;
    var chunk = JsonNode.Parse(payload)!;
    if (chunk["error"] is { } err) throw new Exception($"stream interrupted: {err["message"]}");
    var choices = chunk["choices"]?.AsArray();
    if (choices is { Count: > 0 }) Console.Write(choices[0]!["delta"]?["content"]?.GetValue<string>());
    if (chunk["usage"] is JsonObject usage) Console.WriteLine($"\n[{usage["prompt_tokens"]} in / {usage["completion_tokens"]} out tokens]");
}

Without HttpCompletionOption.ResponseHeadersRead, HttpClient buffers the whole response and nothing prints until the end. The infinite timeout stops long replies being cut off at the 100-second default; cancel with a CancellationToken instead.

What the code handles

  • Lines that start with data: carry JSON chunks; other lines (blank lines, : keep-alive comments) are skipped.
  • data: [DONE] ends the stream.
  • A chunk with an error object means the stream broke after it started; the text so far may be incomplete, so retry the whole request.
  • The last chunk before [DONE] carries usage when you send stream_options.include_usage.

See Stream format for every field.

Was this page helpful?