|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Xunit;
namespace Microsoft.Extensions.AI;
public class ChatClientStructuredOutputExtensionsTests
{
[Fact]
public async Task SuccessUsage()
{
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))])
{
CompletionId = "test",
CreatedAt = DateTimeOffset.UtcNow,
ModelId = "someModel",
RawRepresentation = new object(),
Usage = new(),
};
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) =>
{
var responseFormat = Assert.IsType<ChatResponseFormatJson>(options!.ResponseFormat);
Assert.Null(responseFormat.Schema);
Assert.Null(responseFormat.SchemaName);
Assert.Null(responseFormat.SchemaDescription);
// The inner client receives a trailing "user" message with the schema instruction
Assert.Collection(messages,
message => Assert.Equal("Hello", message.Text),
message =>
{
Assert.Equal(ChatRole.User, message.Role);
Assert.Contains("Respond with a JSON value", message.Text);
Assert.Contains("https://json-schema.org/draft/2020-12/schema", message.Text);
foreach (Species v in Enum.GetValues(typeof(Species)))
{
Assert.Contains(v.ToString(), message.Text); // All enum values are described as strings
}
});
return Task.FromResult(expectedCompletion);
},
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Animal>(chatHistory);
// The completion contains the deserialized result and other completion properties
Assert.Equal(1, response.Result.Id);
Assert.Equal("Tigger", response.Result.FullName);
Assert.Equal(Species.Tiger, response.Result.Species);
Assert.Equal(expectedCompletion.CompletionId, response.CompletionId);
Assert.Equal(expectedCompletion.CreatedAt, response.CreatedAt);
Assert.Equal(expectedCompletion.ModelId, response.ModelId);
Assert.Same(expectedCompletion.RawRepresentation, response.RawRepresentation);
Assert.Same(expectedCompletion.Usage, response.Usage);
// TryGetResult returns the same value
Assert.True(response.TryGetResult(out var tryGetResultOutput));
Assert.Same(response.Result, tryGetResultOutput);
// Doesn't mutate history (or at least, reverts any changes)
Assert.Equal("Hello", Assert.Single(chatHistory).Text);
}
[Fact]
public async Task WrapsNonObjectValuesInDataProperty()
{
var expectedResult = new { data = 123 };
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))]);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) =>
{
var suppliedSchemaMatch = Regex.Match(messages[1].Text!, "```(.*?)```", RegexOptions.Singleline);
Assert.True(suppliedSchemaMatch.Success);
Assert.Equal("""
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"data": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "integer"
}
},
"additionalProperties": false
}
"""{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"data": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "integer"
}
},
"additionalProperties": false
}
""", suppliedSchemaMatch.Groups[1].Value.Trim());
return Task.FromResult(expectedCompletion);
},
};
var response = await client.CompleteAsync<int>("Hello");
Assert.Equal(123, response.Result);
}
[Fact]
public async Task FailureUsage_InvalidJson()
{
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, "This is not valid JSON")]);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion),
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Animal>(chatHistory);
var ex = Assert.Throws<JsonException>(() => response.Result);
Assert.Contains("invalid", ex.Message);
Assert.False(response.TryGetResult(out var tryGetResult));
Assert.Null(tryGetResult);
}
[Fact]
public async Task FailureUsage_NullJson()
{
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, "null")]);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion),
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Animal>(chatHistory);
var ex = Assert.Throws<InvalidOperationException>(() => response.Result);
Assert.Equal("The deserialized response is null", ex.Message);
Assert.False(response.TryGetResult(out var tryGetResult));
Assert.Null(tryGetResult);
}
[Fact]
public async Task FailureUsage_NoJsonInResponse()
{
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, [new ImageContent("https://example.com")])]);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion),
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Animal>(chatHistory);
var ex = Assert.Throws<InvalidOperationException>(() => response.Result);
Assert.Equal("The response did not contain text to be deserialized", ex.Message);
Assert.False(response.TryGetResult(out var tryGetResult));
Assert.Null(tryGetResult);
}
[Fact]
public async Task CanUseNativeStructuredOutput()
{
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))]);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) =>
{
var responseFormat = Assert.IsType<ChatResponseFormatJson>(options!.ResponseFormat);
Assert.Equal(nameof(Animal), responseFormat.SchemaName);
Assert.Equal("Some test description", responseFormat.SchemaDescription);
Assert.Contains("https://json-schema.org/draft/2020-12/schema", responseFormat.Schema);
foreach (Species v in Enum.GetValues(typeof(Species)))
{
Assert.Contains(v.ToString(), responseFormat.Schema); // All enum values are described as strings
}
// The chat history isn't mutated any further, since native structured output is used instead of a prompt
Assert.Equal("Hello", Assert.Single(messages).Text);
return Task.FromResult(expectedCompletion);
},
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Animal>(chatHistory, useNativeJsonSchema: true);
// The completion contains the deserialized result and other completion properties
Assert.Equal(1, response.Result.Id);
Assert.Equal("Tigger", response.Result.FullName);
Assert.Equal(Species.Tiger, response.Result.Species);
// TryGetResult returns the same value
Assert.True(response.TryGetResult(out var tryGetResultOutput));
Assert.Same(response.Result, tryGetResultOutput);
// History remains unmutated
Assert.Equal("Hello", Assert.Single(chatHistory).Text);
}
[Fact]
public async Task CanUseNativeStructuredOutputWithSanitizedTypeName()
{
var expectedResult = new Data<Animal> { Value = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger } };
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))]);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) =>
{
var responseFormat = Assert.IsType<ChatResponseFormatJson>(options!.ResponseFormat);
Assert.Matches("Data_1", responseFormat.SchemaName);
return Task.FromResult(expectedCompletion);
},
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Data<Animal>>(chatHistory, useNativeJsonSchema: true);
// The completion contains the deserialized result and other completion properties
Assert.Equal(1, response.Result!.Value!.Id);
Assert.Equal("Tigger", response.Result.Value.FullName);
Assert.Equal(Species.Tiger, response.Result.Value.Species);
// TryGetResult returns the same value
Assert.True(response.TryGetResult(out var tryGetResultOutput));
Assert.Same(response.Result, tryGetResultOutput);
// History remains unmutated
Assert.Equal("Hello", Assert.Single(chatHistory).Text);
}
[Fact]
public async Task CanUseNativeStructuredOutputWithArray()
{
var expectedResult = new[] { new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger } };
var payload = new { data = expectedResult };
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(payload))]);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) => Task.FromResult(expectedCompletion)
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Animal[]>(chatHistory, useNativeJsonSchema: true);
// The completion contains the deserialized result and other completion properties
Assert.Single(response.Result!);
Assert.Equal("Tigger", response.Result[0].FullName);
Assert.Equal(Species.Tiger, response.Result[0].Species);
// TryGetResult returns the same value
Assert.True(response.TryGetResult(out var tryGetResultOutput));
Assert.Same(response.Result, tryGetResultOutput);
// History remains unmutated
Assert.Equal("Hello", Assert.Single(chatHistory).Text);
}
[Fact]
public async Task CanSpecifyCustomJsonSerializationOptions()
{
var jso = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, jso))]);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) =>
{
Assert.Collection(messages,
message => Assert.Equal("Hello", message.Text),
message =>
{
Assert.Equal(ChatRole.User, message.Role);
Assert.Contains("Respond with a JSON value", message.Text);
Assert.Contains("https://json-schema.org/draft/2020-12/schema", message.Text);
Assert.DoesNotContain(nameof(Animal.FullName), message.Text); // The JSO uses snake_case
Assert.Contains("full_name", message.Text); // The JSO uses snake_case
Assert.DoesNotContain(nameof(Species.Tiger), message.Text); // The JSO doesn't use enum-to-string conversion
});
return Task.FromResult(expectedCompletion);
},
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Animal>(chatHistory, jso);
// The completion contains the deserialized result and other completion properties
Assert.Equal(1, response.Result.Id);
Assert.Equal("Tigger", response.Result.FullName);
Assert.Equal(Species.Tiger, response.Result.Species);
}
[Fact]
public async Task HandlesBackendReturningMultipleObjects()
{
// A very common failure mode for GPT 3.5 Turbo is that instead of returning a single top-level JSON object,
// it may return multiple, particularly when function calling is involved.
// See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348
// Fortunately we can work around this without breaking any cases of valid output.
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var resultDuplicatedJson = JsonSerializer.Serialize(expectedResult) + Environment.NewLine + JsonSerializer.Serialize(expectedResult);
using var client = new TestChatClient
{
CompleteAsyncCallback = (messages, options, cancellationToken) =>
{
return Task.FromResult(new ChatCompletion([new ChatMessage(ChatRole.Assistant, resultDuplicatedJson)]));
},
};
var chatHistory = new List<ChatMessage> { new(ChatRole.User, "Hello") };
var response = await client.CompleteAsync<Animal>(chatHistory);
// The completion contains the deserialized result and other completion properties
Assert.Equal(1, response.Result.Id);
Assert.Equal("Tigger", response.Result.FullName);
Assert.Equal(Species.Tiger, response.Result.Species);
}
[Description("Some test description")]
private class Animal
{
public int Id { get; set; }
public string? FullName { get; set; }
public Species Species { get; set; }
}
private class Data<T>
{
public T? Value { get; set; }
}
private enum Species
{
Bear,
Tiger,
Walrus,
}
}
|