diff --git a/src/Chat.cs b/src/Chat.cs index 7375691..4022087 100644 --- a/src/Chat.cs +++ b/src/Chat.cs @@ -163,7 +163,7 @@ public IAsyncEnumerable SendAsync(string message, IEnumerable? i /// The message to send /// Tools that the model can make use of, see https://ollama.com/blog/tool-support. By using tools, response streaming is automatically turned off /// Base64 encoded images to send to the model - /// Currently accepts "json" or JsonSchema or null. + /// Accepts "json" or an object created with JsonSerializerOptions.Default.GetJsonSchemaAsNode /// The token to cancel the operation with public IAsyncEnumerable SendAsync(string message, IEnumerable? tools, IEnumerable? imagesAsBase64 = null, object? format = null, CancellationToken cancellationToken = default) => SendAsAsync(ChatRole.User, message, tools: tools, imagesAsBase64: imagesAsBase64, format: format, cancellationToken: cancellationToken); @@ -204,7 +204,7 @@ public IAsyncEnumerable SendAsAsync(ChatRole role, string message, IEnum /// The message to send /// Tools that the model can make use of, see https://ollama.com/blog/tool-support. By using tools, response streaming is automatically turned off /// Base64 encoded images to send to the model - /// Currently accepts "json" or JsonSchema or null. + /// Accepts "json" or an object created with JsonSerializerOptions.Default.GetJsonSchemaAsNode /// The token to cancel the operation with public async IAsyncEnumerable SendAsAsync(ChatRole role, string message, IEnumerable? tools, IEnumerable? imagesAsBase64 = null, object? format = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { diff --git a/src/Models/JsonSchema.cs b/src/Models/JsonSchema.cs deleted file mode 100644 index a75d376..0000000 --- a/src/Models/JsonSchema.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Text.Json.Serialization; - -namespace OllamaSharp.Models; - -public class JsonSchema -{ - /// - /// Gets or sets the type of the schema, default is "object". - /// - [JsonPropertyName("type")] - [Browsable(false)] - [EditorBrowsable(EditorBrowsableState.Never)] - public string? Type { get; set; } = "object"; - - /// - /// Gets or sets the properties of the schema. - /// - [JsonPropertyName("properties")] - public Dictionary? Properties { get; set; } - - /// - /// Gets or sets a list of required fields within the schema. - /// - [JsonPropertyName("required")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IEnumerable? Required { get; set; } - - /// - /// Get the JsonSchema from Type, use typeof(Class) to get the Type of Class. - /// - public static JsonSchema ToJsonSchema(Type type) - { - var properties = type.GetProperties().ToDictionary( - prop => prop.Name, - prop => new Property - { - Type = GetTypeName(prop.PropertyType), - Items = typeof(IEnumerable).IsAssignableFrom(prop.PropertyType) && prop.PropertyType != typeof(string) - ? new Item - { - Type = IsPrimitiveType(prop.PropertyType.IsArray - ? prop.PropertyType.GetElementType()! - : prop.PropertyType.GetGenericArguments().First()) - ? GetTypeName(prop.PropertyType.IsArray - ? prop.PropertyType.GetElementType()! - : prop.PropertyType.GetGenericArguments().First()) - : "object", - Properties = IsPrimitiveType(prop.PropertyType.IsArray - ? prop.PropertyType.GetElementType()! - : prop.PropertyType.GetGenericArguments().First()) - ? null - : ToJsonSchema(prop.PropertyType.IsArray - ? prop.PropertyType.GetElementType()! - : prop.PropertyType.GetGenericArguments().First()).Properties, - Required = IsPrimitiveType(prop.PropertyType.IsArray - ? prop.PropertyType.GetElementType()! - : prop.PropertyType.GetGenericArguments().First()) - ? null - : (prop.PropertyType.IsArray - ? prop.PropertyType.GetElementType()! - : prop.PropertyType.GetGenericArguments().First()).GetProperties() - .Where(info => - !info.PropertyType.IsGenericType || - info.PropertyType.GetGenericTypeDefinition() != typeof(Nullable<>) || - Nullable.GetUnderlyingType(info.PropertyType) != null) - .Select(info => info.Name) - .ToList() - } - : null - } - ); - - var required = type.GetProperties() - .Where(prop => - !prop.PropertyType.IsGenericType || - prop.PropertyType.GetGenericTypeDefinition() != typeof(Nullable<>) || - Nullable.GetUnderlyingType(prop.PropertyType) != null) - .Select(prop => prop.Name) - .ToList(); - - return new JsonSchema { Properties = properties, Required = required }; - } - - private static bool IsPrimitiveType(Type type) - { - return type.IsPrimitive || type == typeof(string) || type == typeof(decimal); - } - - private static string GetTypeName(Type type) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) - { - var underlyingType = type.GetGenericArguments().First(); - return GetTypeName(underlyingType); - } - - var typeCode = System.Type.GetTypeCode(type); - - switch (typeCode) - { - case TypeCode.Int32: - case TypeCode.Int16: - case TypeCode.Byte: - case TypeCode.SByte: - case TypeCode.Int64: - case TypeCode.UInt16: - case TypeCode.UInt32: - case TypeCode.UInt64: - return "integer"; - case TypeCode.Single: - case TypeCode.Double: - case TypeCode.Decimal: - return "number"; - case TypeCode.Boolean: - return "boolean"; - case TypeCode.DateTime: - case TypeCode.String: - return "string"; - case TypeCode.Object: - if (type.IsArray || typeof(IEnumerable).IsAssignableFrom(type)) - return "array"; - return "object"; - default: - return "object"; - } - } -} - -public class Property -{ - /// - /// Gets or sets the type of the property. - /// - [JsonPropertyName("type")] - public string? Type { get; set; } - - /// - /// Gets or sets the items of the property. - /// - [JsonPropertyName("items")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public Item? Items { get; set; } - - /// - /// Gets or sets the description of the property. - /// - [JsonPropertyName("description")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; set; } - - /// - /// Gets or sets the Enum of the property. - /// - [JsonPropertyName("enum")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? Enum { get; set; } -} - -public class Item -{ - /// - /// Gets or sets the type of the item. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("type")] - public string? Type { get; set; } - - /// - /// Gets or sets the properties of the item. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("properties")] - public Dictionary? Properties { get; set; } - - /// - /// Gets or sets a list of required fields within the item. - /// - [JsonPropertyName("required")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IEnumerable? Required { get; set; } -} diff --git a/test/FunctionalTests/JsonSchemaTests.cs b/test/FunctionalTests/JsonSchemaTests.cs new file mode 100644 index 0000000..f100d4b --- /dev/null +++ b/test/FunctionalTests/JsonSchemaTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using System.Text.Json.Schema; +using NUnit.Framework; +using FluentAssertions; +using OllamaSharp; + +namespace Tests.FunctionalTests; + +public class JsonSchemaTests +{ + private readonly Uri _baseUri = new("http://localhost:11434"); + private readonly string _model = "llama3.2:1b"; + + private OllamaApiClient _client = null!; + private Chat _chat = null!; + + [SetUp] + public async Task Setup() + { + _client = new OllamaApiClient(_baseUri); + _chat = new Chat(_client); + + var modelExists = (await _client.ListLocalModelsAsync()).Any(m => m.Name == _model); + if (!modelExists) + await _client.PullModelAsync(_model).ToListAsync(); + } + + [TearDown] + public Task Teardown() + { + _client?.Dispose(); + return Task.CompletedTask; + } + + [Test] + public async Task GenerateSword_ShouldSucceed() + { + var responseSchema = JsonSerializerOptions.Default.GetJsonSchemaAsNode(typeof(Sword)); + + _client.SelectedModel = _model; + + var response = await _chat + .SendAsync(""" + Generate a sword with the name 'Excalibur'. + + Return a valid JSON object like: + { + "Name": "", + "Damage": 0 + } + """, tools: null, format: responseSchema) + .StreamToEndAsync(); + response.Should().NotBeNullOrEmpty(); + + var responseSword = JsonSerializer.Deserialize(response); + responseSword.Should().NotBeNull(); + responseSword.Name.ToLowerInvariant().Should().Contain("excalibur"); + responseSword.Damage.Should().BeOfType(typeof(int)); + } + + private class Sword + { + public required string Name { get; set; } + public required int Damage { get; set; } + } +} \ No newline at end of file