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

281 lines
12 KiB
C#

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