Files
VolcanicAgenticCore/extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAICompletionsApi.cs
EugeneTes 89623e14c2 feat(openai): chat completions provider extension
Streams POST {baseUrl}/chat/completions over SSE (System.Net.ServerSentEvents, no NuGet) and maps delta.content, delta.reasoning_content / delta.reasoning and delta.tool_calls onto AssistantMessageEvents via AssistantMessageBuilder. Configurable through OpenAIProviderOptions so one extension serves OpenAI, OpenRouter, Ollama and other compatible servers; ships the gpt-5 / gpt-5-mini / gpt-4.1 catalogue.
2026-09-25 12:45:43 +00:00

667 lines
27 KiB
C#

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;
/// <summary>
/// Streams <c>POST {baseUrl}/chat/completions</c> (<c>stream: true</c>) and maps the SSE chunks onto
/// <see cref="AssistantMessageEvent"/>s. Never throws: HTTP and transport failures become
/// <see cref="AssistantMessageEvent.Error"/> events.
/// </summary>
/// <param name="options">Endpoint configuration.</param>
/// <param name="http">
/// Optional client. When supplied, make sure its <see cref="HttpClient.Timeout"/> allows long streams
/// (the shared default client uses an infinite timeout and relies on <see cref="StreamOptions.Timeout"/> instead).
/// </param>
public sealed class OpenAICompletionsApi(OpenAIProviderOptions options, HttpClient? http = null)
{
private const string DoneSentinel = "[DONE]";
private static readonly HttpClient SharedHttp = new() { Timeout = Timeout.InfiniteTimeSpan };
/// <summary>Wire serialisation: relaxed escaping, nulls written verbatim (an assistant turn with only tool calls sends <c>content: null</c>).</summary>
private static readonly JsonSerializerOptions WireJson = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
private static readonly HashSet<string> 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<AssistantMessageEvent> 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<AssistantMessageEvent>();
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
/// <summary>Builds the JSON body for one chat completions call. Exposed for tests and for <see cref="StreamOptions.OnPayload"/> consumers.</summary>
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;
}
/// <summary>Provider-neutral level to <c>reasoning_effort</c>. Uses the model's map when present (falling back to the nearest mapped level), else a generic table.</summary>
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<ThinkingLevel>();
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<ContentBlock> content)
{
var hasImages = content.OfType<ImageContent>().Any();
if (!hasImages)
{
var text = string.Concat(content.OfType<TextContent>().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 };
}
/// <summary>Thinking blocks are never replayed; an assistant turn with neither text nor tool calls is skipped.</summary>
private static JsonObject? AssistantNode(AssistantMessage assistant)
{
var text = string.Concat(assistant.Content.OfType<TextContent>().Select(t => t.Text));
var calls = assistant.Content.OfType<ToolCallContent>().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<string>();
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<ToolDescriptor> 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<HttpRequestMessage> 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;
}
/// <summary>Provider, then model, then per-request headers; a <c>null</c> request value removes the header.</summary>
private static Dictionary<string, string?> MergeHeaders(
IReadOnlyDictionary<string, string>? providerHeaders,
IReadOnlyDictionary<string, string>? modelHeaders,
IReadOnlyDictionary<string, string?>? requestHeaders)
{
var merged = new Dictionary<string, string?>(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<string, string>(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<string> 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}";
}
/// <summary><c>{"error":{"message":...}}</c>, <c>{"error":"..."}</c> or <c>{"message":...}</c>.</summary>
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
/// <summary>Folds parsed SSE chunks into the builder, emitting explicit block boundaries so every consumer sees balanced events.</summary>
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;
/// <summary>Set when the stream carried a terminal error (<c>content_filter</c> or an <c>error</c> chunk).</summary>
public string? Failure { get; private set; }
public async ValueTask ProcessAsync(string data, Func<JsonElement, ValueTask>? observer, List<AssistantMessageEvent> 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;
}
}
}
/// <summary>Closes any open block and emits <see cref="AssistantMessageEvent.Done"/>.</summary>
public void Finish(List<AssistantMessageEvent> events)
{
CloseOpen(events);
events.Add(builder.Done(_finish ?? StopReason.Stop));
}
private void MapDelta(JsonElement delta, List<AssistantMessageEvent> 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<AssistantMessageEvent> 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<AssistantMessageEvent> 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<AssistantMessageEvent> events)
{
if (_open == kind) return;
CloseOpen(events);
events.Add(kind == Open.Text ? builder.StartText() : builder.StartThinking());
_open = kind;
}
private void CloseOpen(List<AssistantMessageEvent> 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;
}
}
}