using System.Text.Json.Nodes; using AIStudio.Provider.OpenAI; using AIStudio.Tools.ToolCallingSystem; namespace AIStudio.Tests.Provider.ToolCalling; /// /// Checks how a tool's parameter schema is translated into the form OpenAI's strict mode requires. /// /// /// Strict mode wants every argument required and says "may be left out" by allowing null instead. /// The translation has to keep what the schema means: an argument the tool can do without stays /// one the model can leave empty, and an argument the tool needs stays exactly as it was. /// [TestFixture] public sealed class OpenAIStrictToolSchemaTests { [Test] public void AnOptionalArgumentMayBeNullInstead() { var properties = Converted(ToolParameterSchemaBuilder.Create() .RequiredString("query", "The search query.") .OptionalString("language", "The language.") .OptionalInteger("page", "The page."))["properties"]!; Assert.Multiple(() => { Assert.That(Types(properties["language"]!), Is.EqualTo(new[] { "string", "null" })); Assert.That(Types(properties["page"]!), Is.EqualTo(new[] { "integer", "null" })); }); } [Test] public void AnOptionalChoiceOffersNullAmongItsValues() { var timeRange = Converted(ToolParameterSchemaBuilder.Create() .RequiredString("query", "The search query.") .OptionalEnum("time_range", "The time range.", "day", "month", "year"))["properties"]!["time_range"]!; Assert.Multiple(() => { Assert.That(Types(timeRange), Is.EqualTo(new[] { "string", "null" })); Assert.That(timeRange["enum"]!.AsArray().Select(value => value?.GetValue()), Is.EqualTo(new[] { null, "day", "month", "year" })); }); } [Test] public void ARequiredArgumentStaysAsItIs() { var query = Converted(ToolParameterSchemaBuilder.Create() .RequiredString("query", "The search query.") .OptionalString("language", "The language."))["properties"]!["query"]!; Assert.That(query["type"]!.GetValue(), Is.EqualTo("string")); } [Test] public void EveryArgumentIsRequiredInTheOrderOfTheProperties() { // // The order has to be stable across requests, because prompt caching depends on it. The // optional argument comes first here, so an order taken from the old required list would // show. // var schema = Converted(ToolParameterSchemaBuilder.Create() .OptionalString("language", "The language.") .RequiredString("query", "The search query.")); Assert.That(schema["required"]!.AsArray().Select(name => name!.GetValue()), Is.EqualTo(new[] { "language", "query" })); } [Test] public void ExtraArgumentsStayRefused() { var schema = Converted(ToolParameterSchemaBuilder.Create() .RequiredString("query", "The search query.") .OptionalString("language", "The language.")); Assert.That(schema["additionalProperties"]!.GetValue(), Is.False); } [Test] public void ASchemaWithoutOptionalArgumentsIsLeftUntouched() { var parameters = ToolParameterSchemaBuilder.Create() .RequiredString("url", "The address of the page.") .Build(); var converted = OpenAIStrictToolSchema.FromToolParameters(parameters); Assert.That(JsonNode.DeepEquals(JsonNode.Parse(converted.GetRawText()), JsonNode.Parse(parameters.GetRawText())), Is.True); } /// /// Builds the schema and returns it the way strict mode receives it. /// private static JsonNode Converted(ToolParameterSchemaBuilder builder) => JsonNode.Parse(OpenAIStrictToolSchema.FromToolParameters(builder.Build()).GetRawText())!; /// /// Reads the types a property allows, whether it names one or several. /// private static string[] Types(JsonNode property) => property["type"] switch { JsonArray types => types.Select(type => type!.GetValue()).ToArray(), var type => [type!.GetValue()], }; }