From 89623e14c23ccf560f4cdafa02ce860d57d4d292 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 25 Sep 2026 12:44:56 +0000 Subject: [PATCH] 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. --- .../AssemblyMarker.cs | 1 - .../OpenAICompletionsApi.cs | 666 ++++++++++++++++++ .../OpenAIModels.cs | 72 ++ .../OpenAIProviderExtension.cs | 47 ++ .../OpenAIProviderOptions.cs | 86 +++ .../OpenAI/FakeExtensionApi.cs | 40 ++ .../OpenAI/FakeHttpHandler.cs | 52 ++ .../OpenAI/OpenAIProviderExtensionTests.cs | 104 +++ .../OpenAI/OpenAIRequestBuilderTests.cs | 280 ++++++++ .../OpenAI/OpenAIStreamTests.cs | 342 +++++++++ 10 files changed, 1689 insertions(+), 1 deletion(-) delete mode 100644 extensions/Volcanic.AgenticCore.Extensions.OpenAI/AssemblyMarker.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAICompletionsApi.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIModels.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIProviderExtension.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.OpenAI/OpenAIProviderOptions.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/FakeExtensionApi.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/FakeHttpHandler.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIProviderExtensionTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIRequestBuilderTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/OpenAI/OpenAIStreamTests.cs 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()); + } +}