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.
This commit is contained in:
EugeneTes
2026-09-25 12:44:56 +00:00
committed by VolcanicAgenticCore
parent f25318a803
commit 89623e14c2
10 changed files with 1689 additions and 1 deletions

View File

@@ -1 +0,0 @@
namespace Volcanic.AgenticCore.Extensions.OpenAI; internal static class AssemblyMarker { }

View File

@@ -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;
/// <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;
}
}
}

View File

@@ -0,0 +1,72 @@
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Extensions.OpenAI;
/// <summary>
/// Default model catalogue for the <c>openai</c> provider (<c>openai-completions</c> api).
/// Models leave <see cref="Model.BaseUrl"/> unset so they inherit the provider's base URL when registered,
/// which keeps the same catalogue usable behind a proxy; <see cref="DefaultBaseUrl"/> is the upstream default.
/// </summary>
public static class OpenAIModels
{
public const string ProviderId = "openai";
public const string ApiId = "openai-completions";
public const string DefaultBaseUrl = "https://api.openai.com/v1";
/// <summary>
/// <c>reasoning_effort</c> values accepted by the GPT-5 family on the chat completions API.
/// <see cref="ThinkingLevel.XHigh"/> and <see cref="ThinkingLevel.Max"/> are not offered there and map to <c>null</c> (unsupported).
/// </summary>
public static IReadOnlyDictionary<ThinkingLevel, string?> Gpt5ThinkingLevels { get; } = new Dictionary<ThinkingLevel, string?>
{
[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<Model> All { get; } = [Gpt5, Gpt5Mini, Gpt41];
}

View File

@@ -0,0 +1,47 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Extensions.OpenAI;
/// <summary>
/// Registers one OpenAI-compatible provider. With no options this is OpenAI itself
/// (<c>openai</c>, key from <c>OPENAI_API_KEY</c>, the <see cref="OpenAIModels"/> catalogue);
/// pass <see cref="OpenAIProviderOptions"/> to target OpenRouter, Ollama or any other compatible server.
/// </summary>
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;
}
/// <summary>The registration handed to <see cref="IExtensionApi.RegisterProvider"/>; also usable directly with a model registry.</summary>
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,
};
}

View File

@@ -0,0 +1,86 @@
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Extensions.OpenAI;
/// <summary>
/// Configures one OpenAI-compatible chat completions endpoint. The defaults describe OpenAI itself;
/// use <see cref="OpenRouter"/>, <see cref="Ollama"/> or set the properties by hand for other servers
/// (Groq, llama.cpp, vLLM, LM Studio, ...).
/// </summary>
public sealed record OpenAIProviderOptions
{
/// <summary>Provider id used in model keys (<c>provider/model</c>) and API key lookups.</summary>
public string ProviderId { get; init; } = OpenAIModels.ProviderId;
/// <summary>Display name. <c>null</c> falls back to "OpenAI" for the <c>openai</c> provider, otherwise to <see cref="ProviderId"/>.</summary>
public string? Name { get; init; }
/// <summary>Base URL up to and including the API version segment; <c>/chat/completions</c> is appended.</summary>
public string BaseUrl { get; init; } = OpenAIModels.DefaultBaseUrl;
/// <summary>
/// API key spec (<c>literal</c>, <c>$ENV_VAR</c> or <c>!command</c>). <c>null</c> means <c>$OPENAI_API_KEY</c> for the
/// <c>openai</c> provider; for any other provider it stays <c>null</c> so the registry falls back to <c>auth.json</c> and <c>&lt;PROVIDERID&gt;_API_KEY</c>.
/// </summary>
public string? ApiKeySpec { get; init; }
/// <summary>Models to register. <c>null</c> means <see cref="OpenAIModels.All"/> for the <c>openai</c> provider, otherwise none.</summary>
public IReadOnlyList<Model>? Models { get; init; }
/// <summary>Extra headers sent with every request (OpenRouter attribution, proxy auth, ...).</summary>
public IReadOnlyDictionary<string, string>? Headers { get; init; }
/// <summary>False for local servers that accept unauthenticated requests.</summary>
public bool RequiresApiKey { get; init; } = true;
/// <summary>When true the system prompt is sent with role <c>developer</c> instead of <c>system</c>.</summary>
public bool SupportsDeveloperRole { get; init; }
/// <summary>Name of the output-limit field: OpenAI wants <c>max_completion_tokens</c>; many other servers only know <c>max_tokens</c>.</summary>
public string MaxTokensField { get; init; } = "max_completion_tokens";
/// <summary>Whether to send <c>reasoning_effort</c> for reasoning models. Turn off for servers that reject unknown fields.</summary>
public bool SendReasoningEffort { get; init; } = true;
/// <summary>True when this configuration targets the <c>openai</c> provider id (case-insensitive).</summary>
public bool IsOpenAI => string.Equals(ProviderId, OpenAIModels.ProviderId, StringComparison.OrdinalIgnoreCase);
/// <summary><see cref="ApiKeySpec"/> with the <c>openai</c> default applied.</summary>
public string? EffectiveApiKeySpec => ApiKeySpec ?? (IsOpenAI ? "$OPENAI_API_KEY" : null);
/// <summary><see cref="Models"/> with the <c>openai</c> default applied.</summary>
public IReadOnlyList<Model> EffectiveModels => Models ?? (IsOpenAI ? OpenAIModels.All : []);
/// <summary><see cref="Name"/> with the defaults applied.</summary>
public string EffectiveName => Name ?? (IsOpenAI ? "OpenAI" : ProviderId);
/// <summary>OpenRouter (<c>https://openrouter.ai/api/v1</c>), provider id <c>openrouter</c>, key from <c>OPENROUTER_API_KEY</c>.</summary>
/// <param name="models">Models to expose; OpenRouter ids look like <c>anthropic/claude-sonnet-4</c>.</param>
/// <param name="httpReferer">Optional <c>HTTP-Referer</c> attribution header.</param>
/// <param name="title">Optional <c>X-Title</c> attribution header.</param>
public static OpenAIProviderOptions OpenRouter(IReadOnlyList<Model> models, string? httpReferer = null, string? title = null)
{
Dictionary<string, string>? 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,
};
}
/// <summary>A local Ollama server: no API key, <c>max_tokens</c> instead of <c>max_completion_tokens</c>.</summary>
public static OpenAIProviderOptions Ollama(IReadOnlyList<Model> models, string baseUrl = "http://localhost:11434/v1") => new()
{
ProviderId = "ollama",
Name = "Ollama",
BaseUrl = baseUrl,
Models = models,
RequiresApiKey = false,
MaxTokensField = "max_tokens",
};
}

View File

@@ -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;
/// <summary>Records provider registrations; every other member is unsupported.</summary>
internal sealed class FakeExtensionApi : IExtensionApi
{
public List<ProviderRegistration> 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<TEvent>(ExtensionHandler<TEvent> 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<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> 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<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}

View File

@@ -0,0 +1,52 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.Tests.OpenAI;
/// <summary>Captures every request and answers with a scripted response.</summary>
internal sealed class FakeHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<CapturedRequest> Requests { get; } = [];
public CapturedRequest Single => Assert.Single(Requests);
/// <summary>A 200 <c>text/event-stream</c> response whose body is <c>data:</c> lines for each chunk followed by <c>[DONE]</c>.</summary>
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") });
/// <summary>A raw SSE body without the trailing <c>[DONE]</c>.</summary>
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<string> 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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
var headers = new Dictionary<string, string>(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<string, string> Headers, string? Body)
{
public JsonObject Json => JsonNode.Parse(Body ?? "{}")!.AsObject();
}

View File

@@ -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));
}
}

View File

@@ -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<string>());
}
[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<JsonNode?>(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<string, string> { ["X-Provider"] = "p", ["X-Removed"] = "gone" },
};
var api = new OpenAICompletionsApi(providerOptions, handler.CreateClient());
var options = new StreamOptions
{
ApiKey = "sk-test",
Headers = new Dictionary<string, string?> { ["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"));
}
}

View File

@@ -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<List<AssistantMessageEvent>> 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<AssistantMessageEvent>();
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<AssistantMessageEvent.ThinkingDelta>(events[2]).Delta);
Assert.Equal("Hello ", Assert.IsType<AssistantMessageEvent.TextDelta>(events[5]).Delta);
Assert.Equal("world", Assert.IsType<AssistantMessageEvent.TextDelta>(events[6]).Delta);
Assert.Equal("Hello world", Assert.IsType<AssistantMessageEvent.TextEnd>(events[7]).Content);
var toolStart = Assert.IsType<AssistantMessageEvent.ToolCallStart>(events[8]);
Assert.Equal("call_1", toolStart.Id);
Assert.Equal("bash", toolStart.ToolName);
Assert.Equal("{\"comm", Assert.IsType<AssistantMessageEvent.ToolCallDelta>(events[9]).Delta);
Assert.Equal("and\":\"ls\"}", Assert.IsType<AssistantMessageEvent.ToolCallDelta>(events[10]).Delta);
var done = Assert.IsType<AssistantMessageEvent.Done>(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<ThinkingContent>(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<AssistantMessageEvent.TextDelta>().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<AssistantMessageEvent.ToolCallStart>().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<AssistantMessageEvent.Done>(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<AssistantMessageEvent.Done>(events[^1]);
Assert.Equal(["read", "write"], done.Message.ToolCalls.Select(c => c.Name));
Assert.Equal(2, events.OfType<AssistantMessageEvent.ToolCallEnd>().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<AssistantMessageEvent.ToolCallStart>()).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<AssistantMessageEvent.Error>(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("<html>upstream down</html>"),
});
var events = await CollectAsync(handler);
var error = Assert.IsType<AssistantMessageEvent.Error>(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<AssistantMessageEvent.Error>(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<AssistantMessageEvent.Error>(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<AssistantMessageEvent.Done>(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<AssistantMessageEvent.Done>(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<AssistantMessageEvent.Error>(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<AssistantMessageEvent.Done>(events[^1]);
Assert.Equal("hmm", Assert.IsType<ThinkingContent>(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<AssistantMessageEvent.Error>(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<AssistantMessageEvent.Error>(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<string>();
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<AssistantMessageEvent.Done>(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<AssistantMessageEvent.Done>(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());
}
}