diff --git a/extensions/Volcanic.AgenticCore.Extensions.OpenAI/AssemblyMarker.cs b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/AssemblyMarker.cs
deleted file mode 100644
index cb5d77c..0000000
--- a/extensions/Volcanic.AgenticCore.Extensions.OpenAI/AssemblyMarker.cs
+++ /dev/null
@@ -1 +0,0 @@
-namespace Volcanic.AgenticCore.Extensions.OpenAI; internal static class AssemblyMarker { }
diff --git a/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAICompletionsApi.cs b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAICompletionsApi.cs
new file mode 100644
index 0000000..0b6dc0b
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAICompletionsApi.cs
@@ -0,0 +1,666 @@
+using System.Net.Http.Headers;
+using System.Net.ServerSentEvents;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Volcanic.AgenticCore.Abstractions.Json;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+
+namespace Volcanic.AgenticCore.Extensions.OpenAI;
+
+///
+/// Streams POST {baseUrl}/chat/completions (stream: true) and maps the SSE chunks onto
+/// s. Never throws: HTTP and transport failures become
+/// events.
+///
+/// Endpoint configuration.
+///
+/// Optional client. When supplied, make sure its allows long streams
+/// (the shared default client uses an infinite timeout and relies on instead).
+///
+public sealed class OpenAICompletionsApi(OpenAIProviderOptions options, HttpClient? http = null)
+{
+ private const string DoneSentinel = "[DONE]";
+
+ private static readonly HttpClient SharedHttp = new() { Timeout = Timeout.InfiniteTimeSpan };
+
+ /// Wire serialisation: relaxed escaping, nulls written verbatim (an assistant turn with only tool calls sends content: null).
+ private static readonly JsonSerializerOptions WireJson = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+
+ private static readonly HashSet ContentHeaderNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Allow", "Content-Disposition", "Content-Encoding", "Content-Language", "Content-Length",
+ "Content-Location", "Content-MD5", "Content-Range", "Content-Type", "Expires", "Last-Modified",
+ };
+
+ private readonly HttpClient _http = http ?? SharedHttp;
+
+ public OpenAIProviderOptions Options { get; } = options;
+
+ public async IAsyncEnumerable StreamAsync(
+ Model model,
+ LlmContext context,
+ StreamOptions streamOptions,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var builder = new AssistantMessageBuilder(model);
+
+ using var timeout = streamOptions.Timeout is { } limit ? new CancellationTokenSource(limit) : null;
+ using var linked = timeout is null ? null : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
+ var ct = linked?.Token ?? cancellationToken;
+
+ HttpResponseMessage? response = null;
+ Stream? body = null;
+ string? failure = null;
+ var aborted = false;
+
+ try
+ {
+ using var request = await BuildHttpRequestAsync(model, context, streamOptions, ct).ConfigureAwait(false);
+ response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+
+ if (streamOptions.OnResponse is { } onResponse)
+ await onResponse(Describe(response)).ConfigureAwait(false);
+
+ if (response.IsSuccessStatusCode)
+ body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ else
+ failure = await ReadErrorAsync(response, ct).ConfigureAwait(false);
+ }
+ catch (Exception) when (cancellationToken.IsCancellationRequested)
+ {
+ failure = "aborted";
+ aborted = true;
+ }
+ catch (Exception) when (timeout?.IsCancellationRequested == true)
+ {
+ failure = TimeoutMessage(streamOptions.Timeout!.Value);
+ }
+ catch (Exception ex)
+ {
+ failure = $"Request failed: {ex.Message}";
+ }
+
+ using (response)
+ {
+ if (failure is not null || body is null)
+ {
+ yield return builder.Fail(failure ?? "Request failed: empty response body.", aborted);
+ yield break;
+ }
+
+ yield return builder.Start();
+
+ var mapper = new ChunkMapper(model, builder);
+ var pending = new List();
+ var finished = false;
+
+ await using var events = SseParser.Create(body).EnumerateAsync(ct).GetAsyncEnumerator(ct);
+ while (!finished)
+ {
+ pending.Clear();
+ try
+ {
+ if (!await events.MoveNextAsync().ConfigureAwait(false))
+ {
+ finished = true;
+ }
+ else
+ {
+ var data = events.Current.Data;
+ if (string.Equals(data, DoneSentinel, StringComparison.Ordinal))
+ finished = true;
+ else if (!string.IsNullOrWhiteSpace(data))
+ await mapper.ProcessAsync(data, streamOptions.OnProviderStreamEvent, pending).ConfigureAwait(false);
+ }
+ }
+ catch (Exception) when (cancellationToken.IsCancellationRequested)
+ {
+ failure = "aborted";
+ aborted = true;
+ }
+ catch (Exception) when (timeout?.IsCancellationRequested == true)
+ {
+ failure = TimeoutMessage(streamOptions.Timeout!.Value);
+ }
+ catch (JsonException ex)
+ {
+ failure = $"Malformed stream chunk: {ex.Message}";
+ }
+ catch (Exception ex)
+ {
+ failure = $"Stream failed: {ex.Message}";
+ }
+
+ foreach (var e in pending) yield return e;
+
+ failure ??= mapper.Failure;
+ if (failure is not null)
+ {
+ yield return builder.Fail(failure, aborted);
+ yield break;
+ }
+ }
+
+ pending.Clear();
+ mapper.Finish(pending);
+ foreach (var e in pending) yield return e;
+ }
+ }
+
+ // ---------------------------------------------------------------- request
+
+ /// Builds the JSON body for one chat completions call. Exposed for tests and for consumers.
+ public static JsonObject BuildRequest(OpenAIProviderOptions options, Model model, LlmContext context, StreamOptions streamOptions)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(model);
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(streamOptions);
+
+ var reasoningLevel = model.Reasoning && streamOptions.Reasoning is { } level && level != ThinkingLevel.Off ? level : (ThinkingLevel?)null;
+
+ var body = new JsonObject
+ {
+ ["model"] = model.Id,
+ ["stream"] = true,
+ ["stream_options"] = new JsonObject { ["include_usage"] = true },
+ [options.MaxTokensField] = streamOptions.MaxTokens ?? model.MaxTokens,
+ };
+
+ // Reasoning models reject sampling parameters.
+ if (reasoningLevel is null && streamOptions.Temperature is { } temperature)
+ body["temperature"] = temperature;
+
+ body["messages"] = BuildMessages(options, context);
+
+ if (context.Tools.Count > 0)
+ body["tools"] = BuildTools(context.Tools);
+
+ if (reasoningLevel is { } active && options.SendReasoningEffort && ResolveReasoningEffort(model, active) is { } effort)
+ body["reasoning_effort"] = effort;
+
+ if (!string.IsNullOrEmpty(streamOptions.SessionId))
+ body["prompt_cache_key"] = streamOptions.SessionId;
+
+ return body;
+ }
+
+ /// Provider-neutral level to reasoning_effort. Uses the model's map when present (falling back to the nearest mapped level), else a generic table.
+ public static string? ResolveReasoningEffort(Model model, ThinkingLevel level)
+ {
+ if (level == ThinkingLevel.Off) return null;
+
+ var map = model.ThinkingLevelMap;
+ if (map is null) return DefaultEffort(level);
+
+ if (map.TryGetValue(level, out var mapped) && mapped is not null) return mapped;
+
+ // Mirror ThinkingLevels.Clamp: nearest supported level, preferring higher.
+ var order = Enum.GetValues();
+ var index = Array.IndexOf(order, level);
+ for (var i = index + 1; i < order.Length; i++)
+ if (map.TryGetValue(order[i], out var higher) && higher is not null) return higher;
+ for (var i = index - 1; i > 0; i--)
+ if (map.TryGetValue(order[i], out var lower) && lower is not null) return lower;
+ return null;
+ }
+
+ private static string DefaultEffort(ThinkingLevel level) => level switch
+ {
+ ThinkingLevel.Minimal or ThinkingLevel.Low => "low",
+ ThinkingLevel.Medium => "medium",
+ _ => "high",
+ };
+
+ private static JsonArray BuildMessages(OpenAIProviderOptions options, LlmContext context)
+ {
+ var messages = new JsonArray();
+
+ if (!string.IsNullOrWhiteSpace(context.SystemPrompt))
+ {
+ messages.Add(new JsonObject
+ {
+ ["role"] = options.SupportsDeveloperRole ? "developer" : "system",
+ ["content"] = context.SystemPrompt,
+ });
+ }
+
+ foreach (var message in context.Messages)
+ {
+ switch (message)
+ {
+ case UserMessage user:
+ messages.Add(UserNode(user.Content));
+ break;
+ case CustomMessage custom:
+ messages.Add(UserNode(custom.Content));
+ break;
+ case AssistantMessage assistant:
+ if (AssistantNode(assistant) is { } node) messages.Add(node);
+ break;
+ case ToolResultMessage result:
+ messages.Add(ToolResultNode(result));
+ break;
+ }
+ }
+
+ return messages;
+ }
+
+ private static JsonObject UserNode(IReadOnlyList content)
+ {
+ var hasImages = content.OfType().Any();
+ if (!hasImages)
+ {
+ var text = string.Concat(content.OfType().Select(t => t.Text));
+ return new JsonObject { ["role"] = "user", ["content"] = text };
+ }
+
+ var parts = new JsonArray();
+ foreach (var block in content)
+ {
+ switch (block)
+ {
+ case TextContent { Text.Length: > 0 } t:
+ parts.Add(new JsonObject { ["type"] = "text", ["text"] = t.Text });
+ break;
+ case ImageContent image:
+ parts.Add(new JsonObject
+ {
+ ["type"] = "image_url",
+ ["image_url"] = new JsonObject { ["url"] = $"data:{image.MimeType};base64,{image.Data}" },
+ });
+ break;
+ }
+ }
+ return new JsonObject { ["role"] = "user", ["content"] = parts };
+ }
+
+ /// Thinking blocks are never replayed; an assistant turn with neither text nor tool calls is skipped.
+ private static JsonObject? AssistantNode(AssistantMessage assistant)
+ {
+ var text = string.Concat(assistant.Content.OfType().Select(t => t.Text));
+ var calls = assistant.Content.OfType().ToList();
+ if (text.Length == 0 && calls.Count == 0) return null;
+
+ var node = new JsonObject
+ {
+ ["role"] = "assistant",
+ ["content"] = text.Length > 0 ? text : null,
+ };
+
+ if (calls.Count > 0)
+ {
+ var toolCalls = new JsonArray();
+ foreach (var call in calls)
+ {
+ toolCalls.Add(new JsonObject
+ {
+ ["id"] = call.Id,
+ ["type"] = "function",
+ ["function"] = new JsonObject
+ {
+ ["name"] = call.Name,
+ ["arguments"] = call.Arguments.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null ? "{}" : call.Arguments.GetRawText(),
+ },
+ });
+ }
+ node["tool_calls"] = toolCalls;
+ }
+
+ return node;
+ }
+
+ private static JsonObject ToolResultNode(ToolResultMessage result)
+ {
+ var parts = new List();
+ foreach (var block in result.Content)
+ {
+ switch (block)
+ {
+ case TextContent t: parts.Add(t.Text); break;
+ case ImageContent: parts.Add("[image]"); break;
+ }
+ }
+ return new JsonObject
+ {
+ ["role"] = "tool",
+ ["tool_call_id"] = result.ToolCallId,
+ ["content"] = string.Join("\n", parts),
+ };
+ }
+
+ private static JsonArray BuildTools(IReadOnlyList tools)
+ {
+ var array = new JsonArray();
+ foreach (var tool in tools)
+ {
+ array.Add(new JsonObject
+ {
+ ["type"] = "function",
+ ["function"] = new JsonObject
+ {
+ ["name"] = tool.Name,
+ ["description"] = tool.Description,
+ ["parameters"] = tool.Parameters.ValueKind == JsonValueKind.Object
+ ? JsonNode.Parse(tool.Parameters.GetRawText())
+ : new JsonObject { ["type"] = "object", ["properties"] = new JsonObject() },
+ },
+ });
+ }
+ return array;
+ }
+
+ private async Task BuildHttpRequestAsync(Model model, LlmContext context, StreamOptions streamOptions, CancellationToken ct)
+ {
+ JsonNode payload = BuildRequest(Options, model, context, streamOptions);
+ if (streamOptions.OnPayload is { } onPayload)
+ payload = await onPayload(payload).ConfigureAwait(false) ?? payload;
+
+ var baseUrl = string.IsNullOrEmpty(model.BaseUrl) ? Options.BaseUrl : model.BaseUrl;
+ var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl.TrimEnd('/')}/chat/completions")
+ {
+ Content = new StringContent(payload.ToJsonString(WireJson), Encoding.UTF8, "application/json"),
+ };
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
+ if (!string.IsNullOrEmpty(streamOptions.ApiKey))
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", streamOptions.ApiKey);
+
+ foreach (var (name, value) in MergeHeaders(Options.Headers, model.Headers, streamOptions.Headers))
+ SetHeader(request, name, value);
+
+ ct.ThrowIfCancellationRequested();
+ return request;
+ }
+
+ /// Provider, then model, then per-request headers; a null request value removes the header.
+ private static Dictionary MergeHeaders(
+ IReadOnlyDictionary? providerHeaders,
+ IReadOnlyDictionary? modelHeaders,
+ IReadOnlyDictionary? requestHeaders)
+ {
+ var merged = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (providerHeaders is not null) foreach (var (k, v) in providerHeaders) merged[k] = v;
+ if (modelHeaders is not null) foreach (var (k, v) in modelHeaders) merged[k] = v;
+ if (requestHeaders is not null) foreach (var (k, v) in requestHeaders) merged[k] = v;
+ return merged;
+ }
+
+ private static void SetHeader(HttpRequestMessage request, string name, string? value)
+ {
+ HttpHeaders target = ContentHeaderNames.Contains(name) && request.Content is { } content ? content.Headers : request.Headers;
+ target.Remove(name);
+ if (value is not null) target.TryAddWithoutValidation(name, value);
+ }
+
+ // ---------------------------------------------------------------- response
+
+ private static ProviderResponseInfo Describe(HttpResponseMessage response)
+ {
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (name, values) in response.Headers) headers[name] = string.Join(", ", values);
+ foreach (var (name, values) in response.Content.Headers) headers[name] = string.Join(", ", values);
+ return new ProviderResponseInfo((int)response.StatusCode, headers);
+ }
+
+ private static async Task ReadErrorAsync(HttpResponseMessage response, CancellationToken ct)
+ {
+ var body = "";
+ try { body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); }
+ catch (Exception) when (!ct.IsCancellationRequested) { /* body unavailable; fall back to the status line */ }
+
+ var detail = ExtractErrorMessage(body) ?? Excerpt(body) ?? response.ReasonPhrase ?? "request failed";
+ return $"HTTP {(int)response.StatusCode}: {detail}";
+ }
+
+ /// {"error":{"message":...}}, {"error":"..."} or {"message":...}.
+ private static string? ExtractErrorMessage(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body)) return null;
+ try
+ {
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return null;
+ if (root.TryGetProperty("error", out var error))
+ {
+ if (error.ValueKind == JsonValueKind.String) return error.GetString();
+ if (error.ValueKind == JsonValueKind.Object && TryGetString(error, "message", out var message)) return message;
+ }
+ return TryGetString(root, "message", out var top) ? top : null;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string? Excerpt(string body)
+ {
+ var trimmed = body.Trim();
+ if (trimmed.Length == 0) return null;
+ return trimmed.Length <= 200 ? trimmed : trimmed[..200] + "...";
+ }
+
+ private static string TimeoutMessage(TimeSpan limit) => $"Request timed out after {limit.TotalSeconds:0.###}s";
+
+ private static bool TryGetString(JsonElement element, string name, out string value)
+ {
+ if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String)
+ {
+ value = property.GetString()!;
+ return true;
+ }
+ value = "";
+ return false;
+ }
+
+ private static int GetInt(JsonElement element, string name) =>
+ element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value)
+ ? value
+ : 0;
+
+ // ---------------------------------------------------------------- chunk mapping
+
+ /// Folds parsed SSE chunks into the builder, emitting explicit block boundaries so every consumer sees balanced events.
+ private sealed class ChunkMapper(Model model, AssistantMessageBuilder builder)
+ {
+ private enum Open { None, Text, Thinking, ToolCall }
+
+ private Open _open;
+ private int? _toolIndex;
+ private string? _toolId;
+ private int _nextSyntheticIndex;
+ private bool _idRecorded;
+ private bool _modelRecorded;
+ private StopReason? _finish;
+
+ /// Set when the stream carried a terminal error (content_filter or an error chunk).
+ public string? Failure { get; private set; }
+
+ public async ValueTask ProcessAsync(string data, Func? observer, List events)
+ {
+ var chunk = VolcanicJson.Parse(data);
+ if (observer is not null) await observer(chunk).ConfigureAwait(false);
+ if (chunk.ValueKind != JsonValueKind.Object) return;
+
+ if (chunk.TryGetProperty("error", out var error) && error.ValueKind is JsonValueKind.Object or JsonValueKind.String)
+ {
+ CloseOpen(events);
+ Failure = StreamErrorMessage(error);
+ return;
+ }
+
+ if (!_idRecorded && TryGetString(chunk, "id", out var id))
+ {
+ builder.SetResponseId(id);
+ _idRecorded = true;
+ }
+ if (!_modelRecorded && TryGetString(chunk, "model", out var responseModel))
+ {
+ builder.SetResponseModel(responseModel);
+ _modelRecorded = true;
+ }
+
+ if (chunk.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
+ ApplyUsage(usage);
+
+ if (chunk.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var choice in choices.EnumerateArray())
+ {
+ if (choice.ValueKind != JsonValueKind.Object) continue;
+ // Only the first completion is streamed; ignore n > 1 siblings.
+ if (choice.TryGetProperty("index", out var choiceIndex) && choiceIndex.ValueKind == JsonValueKind.Number && choiceIndex.GetInt32() != 0) continue;
+
+ if (choice.TryGetProperty("delta", out var delta) && delta.ValueKind == JsonValueKind.Object)
+ MapDelta(delta, events);
+
+ if (TryGetString(choice, "finish_reason", out var finishReason))
+ MapFinishReason(finishReason, events);
+
+ if (Failure is not null) return;
+ }
+ }
+ }
+
+ /// Closes any open block and emits .
+ public void Finish(List events)
+ {
+ CloseOpen(events);
+ events.Add(builder.Done(_finish ?? StopReason.Stop));
+ }
+
+ private void MapDelta(JsonElement delta, List events)
+ {
+ // DeepSeek / vLLM use reasoning_content; OpenRouter uses reasoning.
+ if ((TryGetString(delta, "reasoning_content", out var thinking) || TryGetString(delta, "reasoning", out thinking)) && thinking.Length > 0)
+ {
+ EnsureOpen(Open.Thinking, events);
+ events.Add(builder.AppendThinking(thinking));
+ }
+
+ if (TryGetString(delta, "content", out var text) && text.Length > 0)
+ {
+ EnsureOpen(Open.Text, events);
+ events.Add(builder.AppendText(text));
+ }
+
+ if (delta.TryGetProperty("tool_calls", out var toolCalls) && toolCalls.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var call in toolCalls.EnumerateArray())
+ if (call.ValueKind == JsonValueKind.Object) MapToolCall(call, events);
+ }
+ }
+
+ private void MapToolCall(JsonElement call, List events)
+ {
+ var function = call.TryGetProperty("function", out var f) && f.ValueKind == JsonValueKind.Object ? f : default;
+ TryGetString(call, "id", out var id);
+
+ int index;
+ if (call.TryGetProperty("index", out var indexProperty) && indexProperty.ValueKind == JsonValueKind.Number && indexProperty.TryGetInt32(out var explicitIndex))
+ {
+ index = explicitIndex;
+ }
+ else
+ {
+ // No index (older servers): a fresh id starts a new call, otherwise the fragment continues the open one.
+ var continues = _open == Open.ToolCall && (id.Length == 0 || string.Equals(id, _toolId, StringComparison.Ordinal));
+ index = continues ? _toolIndex!.Value : _nextSyntheticIndex++;
+ }
+
+ if (_open != Open.ToolCall || _toolIndex != index)
+ {
+ CloseOpen(events);
+ var callId = id.Length > 0 ? id : $"call_{index}";
+ var name = TryGetString(function, "name", out var n) ? n : "";
+ events.Add(builder.StartToolCall(callId, name));
+ _open = Open.ToolCall;
+ _toolIndex = index;
+ _toolId = callId;
+ _nextSyntheticIndex = Math.Max(_nextSyntheticIndex, index + 1);
+ }
+
+ if (function.ValueKind == JsonValueKind.Object && function.TryGetProperty("arguments", out var arguments))
+ {
+ var fragment = arguments.ValueKind switch
+ {
+ JsonValueKind.String => arguments.GetString()!,
+ JsonValueKind.Object or JsonValueKind.Array => arguments.GetRawText(),
+ _ => "",
+ };
+ if (fragment.Length > 0) events.Add(builder.AppendToolCallDelta(fragment));
+ }
+ }
+
+ private void MapFinishReason(string reason, List events)
+ {
+ switch (reason)
+ {
+ case "stop":
+ _finish = StopReason.Stop;
+ break;
+ case "length":
+ _finish = StopReason.Length;
+ break;
+ case "tool_calls":
+ case "function_call":
+ _finish = StopReason.ToolUse;
+ break;
+ case "content_filter":
+ CloseOpen(events);
+ Failure = "content_filter";
+ break;
+ default:
+ _finish ??= StopReason.Stop;
+ break;
+ }
+ }
+
+ private void ApplyUsage(JsonElement usage)
+ {
+ var prompt = GetInt(usage, "prompt_tokens");
+ var completion = GetInt(usage, "completion_tokens");
+ var cached = usage.TryGetProperty("prompt_tokens_details", out var promptDetails) ? GetInt(promptDetails, "cached_tokens") : 0;
+ var reasoning = usage.TryGetProperty("completion_tokens_details", out var completionDetails) ? GetInt(completionDetails, "reasoning_tokens") : 0;
+
+ var tokens = new Usage(Math.Max(0, prompt - cached), completion, cached, 0, reasoning);
+ builder.SetUsage(tokens with { Cost = model.CalculateCost(tokens) });
+ }
+
+ private void EnsureOpen(Open kind, List events)
+ {
+ if (_open == kind) return;
+ CloseOpen(events);
+ events.Add(kind == Open.Text ? builder.StartText() : builder.StartThinking());
+ _open = kind;
+ }
+
+ private void CloseOpen(List events)
+ {
+ switch (_open)
+ {
+ case Open.Text: events.Add(builder.EndText()); break;
+ case Open.Thinking: events.Add(builder.EndThinking()); break;
+ case Open.ToolCall: events.Add(builder.EndToolCall()); break;
+ }
+ _open = Open.None;
+ _toolIndex = null;
+ _toolId = null;
+ }
+
+ private static string StreamErrorMessage(JsonElement error)
+ {
+ if (error.ValueKind == JsonValueKind.String) return error.GetString()!;
+ var message = TryGetString(error, "message", out var m) ? m : error.GetRawText();
+ if (error.TryGetProperty("code", out var code) && code.ValueKind is JsonValueKind.Number or JsonValueKind.String)
+ return $"{message} (code {code.ToString()})";
+ return message;
+ }
+ }
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIModels.cs b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIModels.cs
new file mode 100644
index 0000000..b23752a
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIModels.cs
@@ -0,0 +1,72 @@
+using Volcanic.AgenticCore.Abstractions.Providers;
+
+namespace Volcanic.AgenticCore.Extensions.OpenAI;
+
+///
+/// Default model catalogue for the openai provider (openai-completions api).
+/// Models leave unset so they inherit the provider's base URL when registered,
+/// which keeps the same catalogue usable behind a proxy; is the upstream default.
+///
+public static class OpenAIModels
+{
+ public const string ProviderId = "openai";
+ public const string ApiId = "openai-completions";
+ public const string DefaultBaseUrl = "https://api.openai.com/v1";
+
+ ///
+ /// reasoning_effort values accepted by the GPT-5 family on the chat completions API.
+ /// and are not offered there and map to null (unsupported).
+ ///
+ public static IReadOnlyDictionary Gpt5ThinkingLevels { get; } = new Dictionary
+ {
+ [ThinkingLevel.Minimal] = "minimal",
+ [ThinkingLevel.Low] = "low",
+ [ThinkingLevel.Medium] = "medium",
+ [ThinkingLevel.High] = "high",
+ [ThinkingLevel.XHigh] = null,
+ [ThinkingLevel.Max] = null,
+ };
+
+ public static Model Gpt5 { get; } = new()
+ {
+ Id = "gpt-5",
+ Name = "GPT-5",
+ Provider = ProviderId,
+ Api = ApiId,
+ Reasoning = true,
+ Input = [ModelInput.Text, ModelInput.Image],
+ Cost = new ModelCost(Input: 1.25m, Output: 10m, CacheRead: 0.125m, CacheWrite: 0m),
+ ContextWindow = 400_000,
+ MaxTokens = 128_000,
+ ThinkingLevelMap = Gpt5ThinkingLevels,
+ };
+
+ public static Model Gpt5Mini { get; } = new()
+ {
+ Id = "gpt-5-mini",
+ Name = "GPT-5 mini",
+ Provider = ProviderId,
+ Api = ApiId,
+ Reasoning = true,
+ Input = [ModelInput.Text, ModelInput.Image],
+ Cost = new ModelCost(Input: 0.25m, Output: 2m, CacheRead: 0.025m, CacheWrite: 0m),
+ ContextWindow = 400_000,
+ MaxTokens = 128_000,
+ ThinkingLevelMap = Gpt5ThinkingLevels,
+ };
+
+ public static Model Gpt41 { get; } = new()
+ {
+ Id = "gpt-4.1",
+ Name = "GPT-4.1",
+ Provider = ProviderId,
+ Api = ApiId,
+ Reasoning = false,
+ Input = [ModelInput.Text, ModelInput.Image],
+ Cost = new ModelCost(Input: 2m, Output: 8m, CacheRead: 0.5m, CacheWrite: 0m),
+ ContextWindow = 1_047_576,
+ MaxTokens = 32_768,
+ };
+
+ public static IReadOnlyList All { get; } = [Gpt5, Gpt5Mini, Gpt41];
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIProviderExtension.cs b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIProviderExtension.cs
new file mode 100644
index 0000000..af08227
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIProviderExtension.cs
@@ -0,0 +1,47 @@
+using Volcanic.AgenticCore.Abstractions.Extensions;
+using Volcanic.AgenticCore.Abstractions.Providers;
+
+namespace Volcanic.AgenticCore.Extensions.OpenAI;
+
+///
+/// Registers one OpenAI-compatible provider. With no options this is OpenAI itself
+/// (openai, key from OPENAI_API_KEY, the catalogue);
+/// pass to target OpenRouter, Ollama or any other compatible server.
+///
+public sealed class OpenAIProviderExtension : IExtension
+{
+ private readonly HttpClient? _http;
+
+ public OpenAIProviderExtension() : this(null, null) { }
+
+ public OpenAIProviderExtension(OpenAIProviderOptions? options = null, HttpClient? http = null)
+ {
+ Options = options ?? new OpenAIProviderOptions();
+ _http = http;
+ }
+
+ public OpenAIProviderOptions Options { get; }
+
+ public string Name => $"{Options.ProviderId}-provider";
+
+ public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(api);
+ api.RegisterProvider(CreateRegistration());
+ return ValueTask.CompletedTask;
+ }
+
+ /// The registration handed to ; also usable directly with a model registry.
+ public ProviderRegistration CreateRegistration() => new()
+ {
+ Id = Options.ProviderId,
+ Name = Options.EffectiveName,
+ Api = OpenAIModels.ApiId,
+ BaseUrl = Options.BaseUrl,
+ ApiKey = Options.EffectiveApiKeySpec,
+ Headers = Options.Headers,
+ Models = Options.EffectiveModels,
+ RequiresApiKey = Options.RequiresApiKey,
+ Stream = new OpenAICompletionsApi(Options, _http).StreamAsync,
+ };
+}
diff --git a/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIProviderOptions.cs b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIProviderOptions.cs
new file mode 100644
index 0000000..fbb1a2f
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIProviderOptions.cs
@@ -0,0 +1,86 @@
+using Volcanic.AgenticCore.Abstractions.Providers;
+
+namespace Volcanic.AgenticCore.Extensions.OpenAI;
+
+///
+/// Configures one OpenAI-compatible chat completions endpoint. The defaults describe OpenAI itself;
+/// use , or set the properties by hand for other servers
+/// (Groq, llama.cpp, vLLM, LM Studio, ...).
+///
+public sealed record OpenAIProviderOptions
+{
+ /// Provider id used in model keys (provider/model) and API key lookups.
+ public string ProviderId { get; init; } = OpenAIModels.ProviderId;
+
+ /// Display name. null falls back to "OpenAI" for the openai provider, otherwise to .
+ public string? Name { get; init; }
+
+ /// Base URL up to and including the API version segment; /chat/completions is appended.
+ public string BaseUrl { get; init; } = OpenAIModels.DefaultBaseUrl;
+
+ ///
+ /// API key spec (literal, $ENV_VAR or !command). null means $OPENAI_API_KEY for the
+ /// openai provider; for any other provider it stays null so the registry falls back to auth.json and <PROVIDERID>_API_KEY.
+ ///
+ public string? ApiKeySpec { get; init; }
+
+ /// Models to register. null means for the openai provider, otherwise none.
+ public IReadOnlyList? Models { get; init; }
+
+ /// Extra headers sent with every request (OpenRouter attribution, proxy auth, ...).
+ public IReadOnlyDictionary? Headers { get; init; }
+
+ /// False for local servers that accept unauthenticated requests.
+ public bool RequiresApiKey { get; init; } = true;
+
+ /// When true the system prompt is sent with role developer instead of system.
+ public bool SupportsDeveloperRole { get; init; }
+
+ /// Name of the output-limit field: OpenAI wants max_completion_tokens; many other servers only know max_tokens.
+ public string MaxTokensField { get; init; } = "max_completion_tokens";
+
+ /// Whether to send reasoning_effort for reasoning models. Turn off for servers that reject unknown fields.
+ public bool SendReasoningEffort { get; init; } = true;
+
+ /// True when this configuration targets the openai provider id (case-insensitive).
+ public bool IsOpenAI => string.Equals(ProviderId, OpenAIModels.ProviderId, StringComparison.OrdinalIgnoreCase);
+
+ /// with the openai default applied.
+ public string? EffectiveApiKeySpec => ApiKeySpec ?? (IsOpenAI ? "$OPENAI_API_KEY" : null);
+
+ /// with the openai default applied.
+ public IReadOnlyList EffectiveModels => Models ?? (IsOpenAI ? OpenAIModels.All : []);
+
+ /// with the defaults applied.
+ public string EffectiveName => Name ?? (IsOpenAI ? "OpenAI" : ProviderId);
+
+ /// OpenRouter (https://openrouter.ai/api/v1), provider id openrouter, key from OPENROUTER_API_KEY.
+ /// Models to expose; OpenRouter ids look like anthropic/claude-sonnet-4.
+ /// Optional HTTP-Referer attribution header.
+ /// Optional X-Title attribution header.
+ public static OpenAIProviderOptions OpenRouter(IReadOnlyList models, string? httpReferer = null, string? title = null)
+ {
+ Dictionary? headers = null;
+ if (!string.IsNullOrEmpty(httpReferer)) (headers ??= new(StringComparer.OrdinalIgnoreCase))["HTTP-Referer"] = httpReferer;
+ if (!string.IsNullOrEmpty(title)) (headers ??= new(StringComparer.OrdinalIgnoreCase))["X-Title"] = title;
+ return new OpenAIProviderOptions
+ {
+ ProviderId = "openrouter",
+ Name = "OpenRouter",
+ BaseUrl = "https://openrouter.ai/api/v1",
+ Models = models,
+ Headers = headers,
+ };
+ }
+
+ /// A local Ollama server: no API key, max_tokens instead of max_completion_tokens.
+ public static OpenAIProviderOptions Ollama(IReadOnlyList models, string baseUrl = "http://localhost:11434/v1") => new()
+ {
+ ProviderId = "ollama",
+ Name = "Ollama",
+ BaseUrl = baseUrl,
+ Models = models,
+ RequiresApiKey = false,
+ MaxTokensField = "max_tokens",
+ };
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/FakeExtensionApi.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/FakeExtensionApi.cs
new file mode 100644
index 0000000..becb521
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/FakeExtensionApi.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Volcanic.AgenticCore.Abstractions.Extensions;
+using Volcanic.AgenticCore.Abstractions.Extensions.Events;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Abstractions.Tools;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.OpenAI;
+
+/// Records provider registrations; every other member is unsupported.
+internal sealed class FakeExtensionApi : IExtensionApi
+{
+ public List Providers { get; } = [];
+
+ public string ExtensionName => "fake";
+ public ILogger Logger => NullLogger.Instance;
+ public IEventBus Events => throw new NotSupportedException();
+
+ public void RegisterProvider(ProviderRegistration provider) => Providers.Add(provider);
+ public void UnregisterProvider(string providerId) => Providers.RemoveAll(p => p.Id == providerId);
+
+ public IDisposable On(ExtensionHandler handler) where TEvent : ExtensionEvent => throw new NotSupportedException();
+ public void RegisterTool(ITool tool) => throw new NotSupportedException();
+ public void RegisterCommand(string name, CommandDefinition command) => throw new NotSupportedException();
+ public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => throw new NotSupportedException();
+ public void SendUserMessage(string text, IReadOnlyList? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
+ public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
+ public Task SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
+ public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
+ public IReadOnlyList GetActiveTools() => throw new NotSupportedException();
+ public void SetActiveTools(IEnumerable names) => throw new NotSupportedException();
+ public IReadOnlyList GetAllTools() => throw new NotSupportedException();
+ public IReadOnlyList GetCommands() => throw new NotSupportedException();
+ public string? GetSessionName() => throw new NotSupportedException();
+ public void SetSessionName(string? name) => throw new NotSupportedException();
+ public Task ExecAsync(string command, IReadOnlyList args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/FakeHttpHandler.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/FakeHttpHandler.cs
new file mode 100644
index 0000000..80ce56a
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/FakeHttpHandler.cs
@@ -0,0 +1,52 @@
+using System.Net;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.OpenAI;
+
+/// Captures every request and answers with a scripted response.
+internal sealed class FakeHttpHandler(Func respond) : HttpMessageHandler
+{
+ public List Requests { get; } = [];
+
+ public CapturedRequest Single => Assert.Single(Requests);
+
+ /// A 200 text/event-stream response whose body is data: lines for each chunk followed by [DONE].
+ public static FakeHttpHandler Sse(params string[] chunks) => Sse(HttpStatusCode.OK, chunks);
+
+ public static FakeHttpHandler Sse(HttpStatusCode status, params string[] chunks) =>
+ new(_ => new HttpResponseMessage(status) { Content = new StringContent(SseBody(chunks), Encoding.UTF8, "text/event-stream") });
+
+ /// A raw SSE body without the trailing [DONE].
+ public static FakeHttpHandler RawSse(string body) =>
+ new(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "text/event-stream") });
+
+ public static FakeHttpHandler Json(HttpStatusCode status, string body) =>
+ new(_ => new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") });
+
+ public static string SseBody(IEnumerable chunks)
+ {
+ var sb = new StringBuilder();
+ foreach (var chunk in chunks) sb.Append("data: ").Append(chunk).Append("\n\n");
+ sb.Append("data: [DONE]\n\n");
+ return sb.ToString();
+ }
+
+ public HttpClient CreateClient() => new(this) { Timeout = Timeout.InfiniteTimeSpan };
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (name, values) in request.Headers) headers[name] = string.Join(", ", values);
+ if (request.Content is not null)
+ foreach (var (name, values) in request.Content.Headers) headers[name] = string.Join(", ", values);
+ Requests.Add(new CapturedRequest(request.Method, request.RequestUri!, headers, body));
+ return respond(request);
+ }
+}
+
+internal sealed record CapturedRequest(HttpMethod Method, Uri Uri, IReadOnlyDictionary Headers, string? Body)
+{
+ public JsonObject Json => JsonNode.Parse(Body ?? "{}")!.AsObject();
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIProviderExtensionTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIProviderExtensionTests.cs
new file mode 100644
index 0000000..01b80d3
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIProviderExtensionTests.cs
@@ -0,0 +1,104 @@
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.OpenAI;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.OpenAI;
+
+public class OpenAIProviderExtensionTests
+{
+ [Fact]
+ public async Task Default_registers_openai_with_catalogue_and_env_key()
+ {
+ var api = new FakeExtensionApi();
+ var extension = new OpenAIProviderExtension();
+
+ Assert.Equal("openai-provider", extension.Name);
+ await extension.ActivateAsync(api, CancellationToken.None);
+
+ var registration = Assert.Single(api.Providers);
+ Assert.Equal("openai", registration.Id);
+ Assert.Equal("OpenAI", registration.Name);
+ Assert.Equal("openai-completions", registration.Api);
+ Assert.Equal("https://api.openai.com/v1", registration.BaseUrl);
+ Assert.Equal("$OPENAI_API_KEY", registration.ApiKey);
+ Assert.True(registration.RequiresApiKey);
+ Assert.Null(registration.Headers);
+ Assert.NotNull(registration.Stream);
+
+ Assert.Equal(3, registration.Models.Count);
+ Assert.Equal(["gpt-5", "gpt-5-mini", "gpt-4.1"], registration.Models.Select(m => m.Id));
+ Assert.All(registration.Models, m => Assert.Equal("openai", m.Provider));
+ Assert.All(registration.Models, m => Assert.Equal("openai-completions", m.Api));
+ Assert.All(registration.Models, m => Assert.True(m.SupportsImages));
+ }
+
+ [Fact]
+ public void Catalogue_has_expected_shape()
+ {
+ Assert.True(OpenAIModels.Gpt5.Reasoning);
+ Assert.Equal(400_000, OpenAIModels.Gpt5.ContextWindow);
+ Assert.Equal(128_000, OpenAIModels.Gpt5.MaxTokens);
+ Assert.Equal(new ModelCost(1.25m, 10m, 0.125m, 0m), OpenAIModels.Gpt5.Cost);
+ Assert.Equal("minimal", OpenAIModels.Gpt5.ThinkingLevelMap![ThinkingLevel.Minimal]);
+ Assert.Null(OpenAIModels.Gpt5.ThinkingLevelMap[ThinkingLevel.XHigh]);
+ Assert.Null(OpenAIModels.Gpt5.ThinkingLevelMap[ThinkingLevel.Max]);
+
+ Assert.True(OpenAIModels.Gpt5Mini.Reasoning);
+ Assert.Equal(new ModelCost(0.25m, 2m, 0.025m, 0m), OpenAIModels.Gpt5Mini.Cost);
+
+ Assert.False(OpenAIModels.Gpt41.Reasoning);
+ Assert.Equal(1_047_576, OpenAIModels.Gpt41.ContextWindow);
+ Assert.Equal(32_768, OpenAIModels.Gpt41.MaxTokens);
+ Assert.Null(OpenAIModels.Gpt41.ThinkingLevelMap);
+ }
+
+ [Fact]
+ public async Task OpenRouter_options_register_openrouter_id_with_attribution_headers()
+ {
+ var sonnet = new Model { Id = "anthropic/claude-sonnet-4", Name = "Claude Sonnet 4", Provider = "", Api = "" };
+ var options = OpenAIProviderOptions.OpenRouter([sonnet], httpReferer: "https://volcanic.example", title: "Volcanic");
+ var api = new FakeExtensionApi();
+ var extension = new OpenAIProviderExtension(options);
+
+ Assert.Equal("openrouter-provider", extension.Name);
+ await extension.ActivateAsync(api, CancellationToken.None);
+
+ var registration = Assert.Single(api.Providers);
+ Assert.Equal("openrouter", registration.Id);
+ Assert.Equal("OpenRouter", registration.Name);
+ Assert.Equal("https://openrouter.ai/api/v1", registration.BaseUrl);
+ Assert.Null(registration.ApiKey);
+ Assert.True(registration.RequiresApiKey);
+ Assert.Equal("https://volcanic.example", registration.Headers!["HTTP-Referer"]);
+ Assert.Equal("Volcanic", registration.Headers["X-Title"]);
+ Assert.Equal("anthropic/claude-sonnet-4", Assert.Single(registration.Models).Id);
+ }
+
+ [Fact]
+ public void OpenRouter_without_attribution_has_no_headers()
+ {
+ var options = OpenAIProviderOptions.OpenRouter([]);
+ Assert.Null(options.Headers);
+ Assert.Empty(options.EffectiveModels);
+ }
+
+ [Fact]
+ public void Custom_provider_defaults_to_no_key_spec_and_no_models()
+ {
+ var options = new OpenAIProviderOptions { ProviderId = "groq", BaseUrl = "https://api.groq.com/openai/v1" };
+ var registration = new OpenAIProviderExtension(options).CreateRegistration();
+ Assert.Equal("groq", registration.Id);
+ Assert.Equal("groq", registration.Name);
+ Assert.Null(registration.ApiKey);
+ Assert.Empty(registration.Models);
+ }
+
+ [Fact]
+ public void Explicit_key_spec_and_models_are_passed_through()
+ {
+ var model = new Model { Id = "custom", Name = "Custom", Provider = "", Api = "" };
+ var options = new OpenAIProviderOptions { ApiKeySpec = "!op read secret", Models = [model] };
+ var registration = new OpenAIProviderExtension(options).CreateRegistration();
+ Assert.Equal("!op read secret", registration.ApiKey);
+ Assert.Same(model, Assert.Single(registration.Models));
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIRequestBuilderTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIRequestBuilderTests.cs
new file mode 100644
index 0000000..3ee0f80
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIRequestBuilderTests.cs
@@ -0,0 +1,280 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Volcanic.AgenticCore.Abstractions.Json;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.OpenAI;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.OpenAI;
+
+public class OpenAIRequestBuilderTests
+{
+ private static readonly OpenAIProviderOptions Defaults = new();
+
+ private static JsonObject Build(LlmContext context, StreamOptions? options = null, Model? model = null, OpenAIProviderOptions? providerOptions = null) =>
+ OpenAICompletionsApi.BuildRequest(providerOptions ?? Defaults, model ?? OpenAIModels.Gpt41, context, options ?? StreamOptions.Default);
+
+ private static JsonArray Messages(JsonObject body) => body["messages"]!.AsArray();
+
+ private static AssistantMessage Assistant(params ContentBlock[] content) => new()
+ {
+ Provider = "openai", Api = "openai-completions", Model = "gpt-4.1", StopReason = StopReason.Stop, Content = content,
+ };
+
+ [Fact]
+ public void System_prompt_is_first_with_role_system_by_default()
+ {
+ var body = Build(LlmContext.Simple("be brief", "hi"));
+ var first = Messages(body)[0]!.AsObject();
+ Assert.Equal("system", (string?)first["role"]);
+ Assert.Equal("be brief", (string?)first["content"]);
+ Assert.Equal("user", (string?)Messages(body)[1]!["role"]);
+ }
+
+ [Fact]
+ public void System_prompt_uses_developer_role_when_enabled()
+ {
+ var body = Build(LlmContext.Simple("be brief", "hi"), providerOptions: Defaults with { SupportsDeveloperRole = true });
+ Assert.Equal("developer", (string?)Messages(body)[0]!["role"]);
+ }
+
+ [Fact]
+ public void Blank_system_prompt_is_omitted()
+ {
+ var body = Build(LlmContext.Simple(" ", "hi"));
+ Assert.Single(Messages(body));
+ Assert.Equal("user", (string?)Messages(body)[0]!["role"]);
+ }
+
+ [Fact]
+ public void Text_only_user_message_is_a_plain_string()
+ {
+ var body = Build(LlmContext.Simple(null, "hello"));
+ var user = Messages(body)[0]!.AsObject();
+ Assert.Equal(JsonValueKind.String, user["content"]!.GetValueKind());
+ Assert.Equal("hello", (string?)user["content"]);
+ }
+
+ [Fact]
+ public void User_message_with_image_becomes_content_array_with_data_url()
+ {
+ var message = UserMessage.From("look", [new ImageContent("AAAA", "image/png")]);
+ var body = Build(new LlmContext(null, [message], []));
+ var content = Messages(body)[0]!["content"]!.AsArray();
+ Assert.Equal(2, content.Count);
+ Assert.Equal("text", (string?)content[0]!["type"]);
+ Assert.Equal("look", (string?)content[0]!["text"]);
+ Assert.Equal("image_url", (string?)content[1]!["type"]);
+ Assert.Equal("data:image/png;base64,AAAA", (string?)content[1]!["image_url"]!["url"]);
+ }
+
+ [Fact]
+ public void Custom_message_is_sent_as_user()
+ {
+ var body = Build(new LlmContext(null, [CustomMessage.FromText("note", "remember this")], []));
+ var node = Messages(body)[0]!.AsObject();
+ Assert.Equal("user", (string?)node["role"]);
+ Assert.Equal("remember this", (string?)node["content"]);
+ }
+
+ [Fact]
+ public void Assistant_tool_calls_map_to_tool_calls_with_stringified_arguments_and_thinking_is_dropped()
+ {
+ var assistant = Assistant(
+ new ThinkingContent("secret plan"),
+ new TextContent("Running ls"),
+ new ToolCallContent("call_1", "bash", VolcanicJson.Parse("""{"command":"ls -la"}""")));
+ var body = Build(new LlmContext(null, [UserMessage.FromText("list"), assistant], []));
+
+ var node = Messages(body)[1]!.AsObject();
+ Assert.Equal("assistant", (string?)node["role"]);
+ Assert.Equal("Running ls", (string?)node["content"]);
+ Assert.DoesNotContain("secret plan", body.ToJsonString());
+
+ var call = Assert.Single(node["tool_calls"]!.AsArray())!.AsObject();
+ Assert.Equal("call_1", (string?)call["id"]);
+ Assert.Equal("function", (string?)call["type"]);
+ Assert.Equal("bash", (string?)call["function"]!["name"]);
+ var arguments = call["function"]!["arguments"]!;
+ Assert.Equal(JsonValueKind.String, arguments.GetValueKind());
+ Assert.Equal("ls -la", JsonNode.Parse((string)arguments!)!["command"]!.GetValue());
+ }
+
+ [Fact]
+ public void Assistant_with_only_tool_calls_sends_null_content()
+ {
+ var assistant = Assistant(new ToolCallContent("call_1", "bash", VolcanicJson.EmptyObject));
+ var body = Build(new LlmContext(null, [UserMessage.FromText("go"), assistant], []));
+ var node = Messages(body)[1]!.AsObject();
+ Assert.True(node.ContainsKey("content"));
+ Assert.Null(node["content"]);
+ Assert.Contains("\"content\":null", body.ToJsonString());
+ }
+
+ [Fact]
+ public void Tool_result_maps_to_tool_role_with_tool_call_id_and_images_become_placeholder()
+ {
+ var result = new ToolResultMessage
+ {
+ ToolCallId = "call_1",
+ ToolName = "bash",
+ Content = [new TextContent("file.txt"), new ImageContent("AAAA", "image/png")],
+ };
+ var body = Build(new LlmContext(null, [result], []));
+ var node = Messages(body)[0]!.AsObject();
+ Assert.Equal("tool", (string?)node["role"]);
+ Assert.Equal("call_1", (string?)node["tool_call_id"]);
+ Assert.Equal("file.txt\n[image]", (string?)node["content"]);
+ }
+
+ [Fact]
+ public void Stream_is_enabled_with_usage_in_final_chunk()
+ {
+ var body = Build(LlmContext.Simple(null, "hi"));
+ Assert.True((bool)body["stream"]!);
+ Assert.True((bool)body["stream_options"]!["include_usage"]!);
+ }
+
+ [Fact]
+ public void Max_completion_tokens_defaults_from_model_and_honours_stream_options()
+ {
+ var body = Build(LlmContext.Simple(null, "hi"));
+ Assert.Equal(OpenAIModels.Gpt41.MaxTokens, (int)body["max_completion_tokens"]!);
+ Assert.False(body.ContainsKey("max_tokens"));
+
+ var overridden = Build(LlmContext.Simple(null, "hi"), new StreamOptions { MaxTokens = 512 });
+ Assert.Equal(512, (int)overridden["max_completion_tokens"]!);
+ }
+
+ [Fact]
+ public void Max_tokens_field_name_is_configurable()
+ {
+ var body = Build(LlmContext.Simple(null, "hi"), providerOptions: Defaults with { MaxTokensField = "max_tokens" });
+ Assert.Equal(OpenAIModels.Gpt41.MaxTokens, (int)body["max_tokens"]!);
+ Assert.False(body.ContainsKey("max_completion_tokens"));
+ }
+
+ [Fact]
+ public void Reasoning_effort_is_sent_for_reasoning_models_and_temperature_is_dropped()
+ {
+ var options = new StreamOptions { Reasoning = ThinkingLevel.Medium, Temperature = 0.2 };
+ var body = Build(LlmContext.Simple(null, "hi"), options, OpenAIModels.Gpt5);
+ Assert.Equal("medium", (string?)body["reasoning_effort"]);
+ Assert.False(body.ContainsKey("temperature"));
+ }
+
+ [Fact]
+ public void Reasoning_effort_uses_model_map()
+ {
+ var body = Build(LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.Minimal }, OpenAIModels.Gpt5);
+ Assert.Equal("minimal", (string?)body["reasoning_effort"]);
+ }
+
+ [Fact]
+ public void Unsupported_level_falls_back_to_nearest_mapped_level()
+ {
+ var body = Build(LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.Max }, OpenAIModels.Gpt5);
+ Assert.Equal("high", (string?)body["reasoning_effort"]);
+ }
+
+ [Fact]
+ public void Reasoning_effort_is_absent_for_non_reasoning_models_and_temperature_is_kept()
+ {
+ var options = new StreamOptions { Reasoning = ThinkingLevel.Medium, Temperature = 0.2 };
+ var body = Build(LlmContext.Simple(null, "hi"), options, OpenAIModels.Gpt41);
+ Assert.False(body.ContainsKey("reasoning_effort"));
+ Assert.Equal(0.2, (double)body["temperature"]!);
+ }
+
+ [Fact]
+ public void Reasoning_effort_is_absent_when_off_or_disabled()
+ {
+ var off = Build(LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.Off }, OpenAIModels.Gpt5);
+ Assert.False(off.ContainsKey("reasoning_effort"));
+
+ var disabled = Build(LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.High }, OpenAIModels.Gpt5, Defaults with { SendReasoningEffort = false });
+ Assert.False(disabled.ContainsKey("reasoning_effort"));
+ }
+
+ [Fact]
+ public void Prompt_cache_key_comes_from_session_id()
+ {
+ var body = Build(LlmContext.Simple(null, "hi"), new StreamOptions { SessionId = "session-42" });
+ Assert.Equal("session-42", (string?)body["prompt_cache_key"]);
+
+ var none = Build(LlmContext.Simple(null, "hi"));
+ Assert.False(none.ContainsKey("prompt_cache_key"));
+ }
+
+ [Fact]
+ public void Tools_key_is_omitted_when_empty_and_mapped_when_present()
+ {
+ Assert.False(Build(LlmContext.Simple(null, "hi")).ContainsKey("tools"));
+
+ var parameters = VolcanicJson.Parse("""{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}""");
+ var context = new LlmContext(null, [UserMessage.FromText("hi")], [new ToolDescriptor("bash", "Run a command", parameters)]);
+ var body = Build(context);
+ var tool = Assert.Single(body["tools"]!.AsArray())!.AsObject();
+ Assert.Equal("function", (string?)tool["type"]);
+ Assert.Equal("bash", (string?)tool["function"]!["name"]);
+ Assert.Equal("Run a command", (string?)tool["function"]!["description"]);
+ Assert.Equal("string", (string?)tool["function"]!["parameters"]!["properties"]!["command"]!["type"]);
+ }
+
+ [Fact]
+ public void Model_id_is_sent()
+ {
+ Assert.Equal("gpt-4.1", (string?)Build(LlmContext.Simple(null, "hi"))["model"]);
+ }
+
+ [Fact]
+ public async Task OnPayload_replacement_is_what_gets_sent()
+ {
+ var handler = FakeHttpHandler.Sse("""{"id":"x","model":"m","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}""");
+ var api = new OpenAICompletionsApi(Defaults, handler.CreateClient());
+ var options = new StreamOptions
+ {
+ ApiKey = "sk-test",
+ OnPayload = node =>
+ {
+ var replaced = node.DeepClone().AsObject();
+ replaced["metadata"] = new JsonObject { ["trace"] = "abc" };
+ return ValueTask.FromResult(replaced);
+ },
+ };
+
+ await foreach (var _ in api.StreamAsync(OpenAIModels.Gpt41, LlmContext.Simple(null, "hi"), options, CancellationToken.None)) { }
+
+ var sent = handler.Single.Json;
+ Assert.Equal("abc", (string?)sent["metadata"]!["trace"]);
+ Assert.Equal("gpt-4.1", (string?)sent["model"]);
+ }
+
+ [Fact]
+ public async Task Request_targets_chat_completions_with_bearer_and_merged_headers()
+ {
+ var handler = FakeHttpHandler.Sse("""{"id":"x","model":"m","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}""");
+ var providerOptions = Defaults with
+ {
+ BaseUrl = "https://proxy.example/v1/",
+ Headers = new Dictionary { ["X-Provider"] = "p", ["X-Removed"] = "gone" },
+ };
+ var api = new OpenAICompletionsApi(providerOptions, handler.CreateClient());
+ var options = new StreamOptions
+ {
+ ApiKey = "sk-test",
+ Headers = new Dictionary { ["X-Request"] = "r", ["X-Removed"] = null },
+ };
+
+ await foreach (var _ in api.StreamAsync(OpenAIModels.Gpt41, LlmContext.Simple(null, "hi"), options, CancellationToken.None)) { }
+
+ var request = handler.Single;
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal("https://proxy.example/v1/chat/completions", request.Uri.ToString());
+ Assert.Equal("Bearer sk-test", request.Headers["Authorization"]);
+ Assert.StartsWith("application/json", request.Headers["Content-Type"]);
+ Assert.Equal("p", request.Headers["X-Provider"]);
+ Assert.Equal("r", request.Headers["X-Request"]);
+ Assert.False(request.Headers.ContainsKey("X-Removed"));
+ }
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIStreamTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIStreamTests.cs
new file mode 100644
index 0000000..e9150bc
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIStreamTests.cs
@@ -0,0 +1,342 @@
+using System.Net;
+using System.Text.Json;
+using Volcanic.AgenticCore.Abstractions.Messages;
+using Volcanic.AgenticCore.Abstractions.Providers;
+using Volcanic.AgenticCore.Extensions.OpenAI;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.OpenAI;
+
+public class OpenAIStreamTests
+{
+ private static readonly OpenAIProviderOptions Defaults = new();
+
+ private static readonly string[] Fixture =
+ [
+ """{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5-2025-08-07","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}""",
+ """{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5-2025-08-07","choices":[{"index":0,"delta":{"reasoning_content":"think"},"finish_reason":null}]}""",
+ """{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5-2025-08-07","choices":[{"index":0,"delta":{"content":"Hello "},"finish_reason":null}]}""",
+ """{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5-2025-08-07","choices":[{"index":0,"delta":{"content":"world"},"finish_reason":null}]}""",
+ """{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5-2025-08-07","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"comm"}}]},"finish_reason":null}]}""",
+ """{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5-2025-08-07","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"and\":\"ls\"}"}}]},"finish_reason":null}]}""",
+ """{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5-2025-08-07","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}""",
+ """{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5-2025-08-07","choices":[],"usage":{"prompt_tokens":30,"completion_tokens":8,"total_tokens":38,"prompt_tokens_details":{"cached_tokens":10},"completion_tokens_details":{"reasoning_tokens":2}}}""",
+ ];
+
+ private static async Task> CollectAsync(
+ FakeHttpHandler handler,
+ Model? model = null,
+ OpenAIProviderOptions? providerOptions = null,
+ StreamOptions? options = null,
+ CancellationToken ct = default)
+ {
+ var api = new OpenAICompletionsApi(providerOptions ?? Defaults, handler.CreateClient());
+ var events = new List();
+ var context = LlmContext.Simple("sys", "hi");
+ await foreach (var e in api.StreamAsync(model ?? OpenAIModels.Gpt5, context, options ?? new StreamOptions { ApiKey = "sk-test" }, ct))
+ events.Add(e);
+ return events;
+ }
+
+ [Fact]
+ public async Task Fixture_maps_to_balanced_events_in_order()
+ {
+ var events = await CollectAsync(FakeHttpHandler.Sse(Fixture));
+
+ Type[] expected =
+ [
+ typeof(AssistantMessageEvent.Start),
+ typeof(AssistantMessageEvent.ThinkingStart),
+ typeof(AssistantMessageEvent.ThinkingDelta),
+ typeof(AssistantMessageEvent.ThinkingEnd),
+ typeof(AssistantMessageEvent.TextStart),
+ typeof(AssistantMessageEvent.TextDelta),
+ typeof(AssistantMessageEvent.TextDelta),
+ typeof(AssistantMessageEvent.TextEnd),
+ typeof(AssistantMessageEvent.ToolCallStart),
+ typeof(AssistantMessageEvent.ToolCallDelta),
+ typeof(AssistantMessageEvent.ToolCallDelta),
+ typeof(AssistantMessageEvent.ToolCallEnd),
+ typeof(AssistantMessageEvent.Done),
+ ];
+ Assert.Equal(expected, events.Select(e => e.GetType()).ToArray());
+
+ Assert.Equal("think", Assert.IsType(events[2]).Delta);
+ Assert.Equal("Hello ", Assert.IsType(events[5]).Delta);
+ Assert.Equal("world", Assert.IsType(events[6]).Delta);
+ Assert.Equal("Hello world", Assert.IsType(events[7]).Content);
+
+ var toolStart = Assert.IsType(events[8]);
+ Assert.Equal("call_1", toolStart.Id);
+ Assert.Equal("bash", toolStart.ToolName);
+ Assert.Equal("{\"comm", Assert.IsType(events[9]).Delta);
+ Assert.Equal("and\":\"ls\"}", Assert.IsType(events[10]).Delta);
+
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal(StopReason.ToolUse, done.Reason);
+
+ var message = done.Message;
+ Assert.Equal(StopReason.ToolUse, message.StopReason);
+ Assert.Equal("Hello world", message.Text);
+ Assert.Equal("think", Assert.IsType(message.Content[0]).Thinking);
+ var call = Assert.Single(message.ToolCalls);
+ Assert.Equal("call_1", call.Id);
+ Assert.Equal("bash", call.Name);
+ Assert.Equal("ls", call.Arguments.GetProperty("command").GetString());
+
+ Assert.Equal(20, message.Usage.Input);
+ Assert.Equal(10, message.Usage.CacheRead);
+ Assert.Equal(8, message.Usage.Output);
+ Assert.Equal(2, message.Usage.Reasoning);
+ Assert.Equal(0, message.Usage.CacheWrite);
+ Assert.Equal(OpenAIModels.Gpt5.CalculateCost(message.Usage).Total, message.Usage.Cost.Total);
+ Assert.True(message.Usage.Cost.Total > 0);
+
+ Assert.Equal("chatcmpl-1", message.ResponseId);
+ Assert.Equal("gpt-5-2025-08-07", message.ResponseModel);
+ Assert.Equal("openai", message.Provider);
+ Assert.Equal("openai-completions", message.Api);
+ Assert.Equal("gpt-5", message.Model);
+ }
+
+ [Fact]
+ public async Task Partial_snapshots_accumulate()
+ {
+ var events = await CollectAsync(FakeHttpHandler.Sse(Fixture));
+ var second = events.OfType().Last();
+ Assert.Equal("Hello world", second.Partial.Text);
+ Assert.Equal(StopReason.Pending, second.Partial.StopReason);
+ }
+
+ [Fact]
+ public async Task Two_tool_calls_at_different_indices_are_both_emitted()
+ {
+ var handler = FakeHttpHandler.Sse(
+ """{"id":"c2","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"read","arguments":"{\"path\":\"a\"}"}}]},"finish_reason":null}]}""",
+ """{"id":"c2","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_b","type":"function","function":{"name":"write","arguments":"{\"path\":"}}]},"finish_reason":null}]}""",
+ """{"id":"c2","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"b\",\"text\":\"x\"}"}}]},"finish_reason":null}]}""",
+ """{"id":"c2","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}""");
+
+ var events = await CollectAsync(handler);
+
+ var starts = events.OfType().ToList();
+ Assert.Equal(["call_a", "call_b"], starts.Select(s => s.Id));
+ Assert.Equal(["read", "write"], starts.Select(s => s.ToolName));
+ Assert.Equal([0, 1], starts.Select(s => s.ContentIndex));
+
+ var kinds = events.Select(e => e.GetType()).ToArray();
+ Assert.Equal(
+ [
+ typeof(AssistantMessageEvent.Start),
+ typeof(AssistantMessageEvent.ToolCallStart),
+ typeof(AssistantMessageEvent.ToolCallDelta),
+ typeof(AssistantMessageEvent.ToolCallEnd),
+ typeof(AssistantMessageEvent.ToolCallStart),
+ typeof(AssistantMessageEvent.ToolCallDelta),
+ typeof(AssistantMessageEvent.ToolCallDelta),
+ typeof(AssistantMessageEvent.ToolCallEnd),
+ typeof(AssistantMessageEvent.Done),
+ ], kinds);
+
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal(StopReason.ToolUse, done.Reason);
+ Assert.Equal(2, done.Message.ToolCalls.Count);
+ Assert.Equal("a", done.Message.ToolCalls[0].Arguments.GetProperty("path").GetString());
+ Assert.Equal("b", done.Message.ToolCalls[1].Arguments.GetProperty("path").GetString());
+ Assert.Equal("x", done.Message.ToolCalls[1].Arguments.GetProperty("text").GetString());
+ }
+
+ [Fact]
+ public async Task Two_tool_calls_in_one_delta_are_both_emitted()
+ {
+ var handler = FakeHttpHandler.Sse(
+ """{"id":"c3","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"read","arguments":"{}"}},{"index":1,"id":"call_b","function":{"name":"write","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}""");
+
+ var events = await CollectAsync(handler);
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal(["read", "write"], done.Message.ToolCalls.Select(c => c.Name));
+ Assert.Equal(2, events.OfType().Count());
+ }
+
+ [Fact]
+ public async Task Tool_call_without_id_gets_synthetic_id()
+ {
+ var handler = FakeHttpHandler.Sse(
+ """{"id":"c4","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":3,"function":{"name":"bash","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}""");
+ var events = await CollectAsync(handler);
+ Assert.Equal("call_3", Assert.Single(events.OfType()).Id);
+ }
+
+ [Fact]
+ public async Task Http_429_becomes_a_single_error_event()
+ {
+ var handler = FakeHttpHandler.Json(HttpStatusCode.TooManyRequests, """{"error":{"message":"Rate limit reached","type":"tokens"}}""");
+ var events = await CollectAsync(handler);
+
+ var error = Assert.IsType(Assert.Single(events));
+ Assert.Equal(StopReason.Error, error.Reason);
+ Assert.Contains("429", error.Message.ErrorMessage);
+ Assert.Contains("Rate limit reached", error.Message.ErrorMessage);
+ Assert.True(error.Message.IsFailure);
+ }
+
+ [Fact]
+ public async Task Http_error_without_json_body_uses_an_excerpt()
+ {
+ var handler = new FakeHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway)
+ {
+ Content = new StringContent("upstream down"),
+ });
+ var events = await CollectAsync(handler);
+ var error = Assert.IsType(Assert.Single(events));
+ Assert.Contains("502", error.Message.ErrorMessage);
+ Assert.Contains("upstream down", error.Message.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task Error_chunk_inside_the_stream_fails_and_keeps_streamed_text()
+ {
+ var handler = FakeHttpHandler.Sse(
+ """{"id":"c5","model":"m","choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":null}]}""",
+ """{"error":{"message":"Provider overloaded","code":502}}""");
+ var events = await CollectAsync(handler);
+
+ var error = Assert.IsType(events[^1]);
+ Assert.Contains("Provider overloaded", error.Message.ErrorMessage);
+ Assert.Contains("502", error.Message.ErrorMessage);
+ Assert.Equal("partial", error.Message.Text);
+ Assert.Contains(events, e => e is AssistantMessageEvent.TextEnd);
+ Assert.Single(events, e => e.IsTerminal);
+ }
+
+ [Fact]
+ public async Task Content_filter_finish_reason_fails()
+ {
+ var handler = FakeHttpHandler.Sse(
+ """{"id":"c6","model":"m","choices":[{"index":0,"delta":{"content":"nope"},"finish_reason":"content_filter"}]}""");
+ var events = await CollectAsync(handler);
+ var error = Assert.IsType(events[^1]);
+ Assert.Equal("content_filter", error.Message.ErrorMessage);
+ Assert.Single(events, e => e.IsTerminal);
+ }
+
+ [Fact]
+ public async Task Length_finish_reason_maps_to_length()
+ {
+ var handler = FakeHttpHandler.Sse(
+ """{"id":"c7","model":"m","choices":[{"index":0,"delta":{"content":"truncated"},"finish_reason":"length"}]}""");
+ var events = await CollectAsync(handler);
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal(StopReason.Length, done.Reason);
+ Assert.Equal("truncated", done.Message.Text);
+ }
+
+ [Fact]
+ public async Task Stream_without_done_sentinel_still_completes()
+ {
+ var handler = FakeHttpHandler.RawSse("data: {\"id\":\"c8\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n");
+ var events = await CollectAsync(handler);
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal(StopReason.Stop, done.Reason);
+ Assert.Equal("hi", done.Message.Text);
+ }
+
+ [Fact]
+ public async Task Malformed_chunk_fails_without_throwing()
+ {
+ var handler = FakeHttpHandler.Sse("{not json");
+ var events = await CollectAsync(handler);
+ var error = Assert.IsType(events[^1]);
+ Assert.Contains("Malformed stream chunk", error.Message.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task Openrouter_style_reasoning_field_maps_to_thinking()
+ {
+ var handler = FakeHttpHandler.Sse(
+ """{"id":"c9","model":"m","choices":[{"index":0,"delta":{"reasoning":"hmm","content":null},"finish_reason":null}]}""",
+ """{"id":"c9","model":"m","choices":[{"index":0,"delta":{"reasoning":null,"content":"ok"},"finish_reason":"stop"}]}""");
+ var events = await CollectAsync(handler);
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal("hmm", Assert.IsType(done.Message.Content[0]).Thinking);
+ Assert.Equal("ok", done.Message.Text);
+ }
+
+ [Fact]
+ public async Task Cancelled_token_aborts_without_throwing()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var events = await CollectAsync(FakeHttpHandler.Sse(Fixture), ct: cts.Token);
+ var error = Assert.IsType(Assert.Single(events));
+ Assert.Equal(StopReason.Aborted, error.Reason);
+ Assert.Equal("aborted", error.Message.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task Transport_failure_becomes_error_event()
+ {
+ var handler = new FakeHttpHandler(_ => throw new HttpRequestException("connection refused"));
+ var events = await CollectAsync(handler);
+ var error = Assert.IsType(Assert.Single(events));
+ Assert.Contains("connection refused", error.Message.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task OnResponse_and_OnProviderStreamEvent_are_invoked()
+ {
+ ProviderResponseInfo? info = null;
+ var seen = new List();
+ var options = new StreamOptions
+ {
+ ApiKey = "sk-test",
+ OnResponse = i => { info = i; return ValueTask.CompletedTask; },
+ OnProviderStreamEvent = e => { seen.Add(e.GetProperty("id").GetString()!); return ValueTask.CompletedTask; },
+ };
+
+ var events = await CollectAsync(FakeHttpHandler.Sse(Fixture), options: options);
+
+ Assert.IsType(events[^1]);
+ Assert.NotNull(info);
+ Assert.Equal(200, info!.Status);
+ Assert.StartsWith("text/event-stream", info.Headers["Content-Type"]);
+ Assert.Equal(Fixture.Length, seen.Count);
+ Assert.All(seen, id => Assert.Equal("chatcmpl-1", id));
+ }
+
+ [Fact]
+ public async Task Ollama_options_send_no_authorization_and_use_max_tokens()
+ {
+ var llama = new Model { Id = "llama3.1", Name = "Llama 3.1", Provider = "ollama", Api = OpenAIModels.ApiId, MaxTokens = 2048 };
+ var providerOptions = OpenAIProviderOptions.Ollama([llama]);
+ var handler = FakeHttpHandler.Sse(
+ """{"id":"o1","model":"llama3.1","choices":[{"index":0,"delta":{"content":"hey"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1}}""");
+
+ var events = await CollectAsync(handler, llama, providerOptions, new StreamOptions { ApiKey = null });
+
+ var request = handler.Single;
+ Assert.Equal("http://localhost:11434/v1/chat/completions", request.Uri.ToString());
+ Assert.False(request.Headers.ContainsKey("Authorization"));
+ Assert.Equal(2048, (int)request.Json["max_tokens"]!);
+ Assert.False(request.Json.ContainsKey("max_completion_tokens"));
+
+ var done = Assert.IsType(events[^1]);
+ Assert.Equal("hey", done.Message.Text);
+ Assert.Equal(5, done.Message.Usage.Input);
+ Assert.Equal(1, done.Message.Usage.Output);
+
+ var registration = new OpenAIProviderExtension(providerOptions).CreateRegistration();
+ Assert.Equal("ollama", registration.Id);
+ Assert.False(registration.RequiresApiKey);
+ Assert.Null(registration.ApiKey);
+ Assert.Equal("http://localhost:11434/v1", registration.BaseUrl);
+ }
+
+ [Fact]
+ public async Task Model_base_url_overrides_provider_base_url()
+ {
+ var model = OpenAIModels.Gpt41 with { BaseUrl = "https://eu.example/v1" };
+ var handler = FakeHttpHandler.Sse("""{"id":"x","model":"m","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}""");
+ await CollectAsync(handler, model);
+ Assert.Equal("https://eu.example/v1/chat/completions", handler.Single.Uri.ToString());
+ }
+}