feat: add models for streaming messages

This commit is contained in:
Stevan Freeborn
2024-06-28 10:54:00 -05:00
parent 422719f509
commit a4784fd5db
27 changed files with 551 additions and 111 deletions
@@ -19,6 +19,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageReference Include="System.Text.Json" Version="8.0.3" />
</ItemGroup>
@@ -0,0 +1,28 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using AnthropicClient.Models;
namespace AnthropicClient.Json;
class ContentDeltaConverter : JsonConverter<ContentDelta>
{
public override ContentDelta Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var jsonDocument = JsonDocument.ParseValue(ref reader);
var root = jsonDocument.RootElement;
var type = root.GetProperty("type").GetString();
return type switch
{
ContentDeltaType.TextDelta => JsonSerializer.Deserialize<TextDelta>(root.GetRawText(), options)!,
ContentDeltaType.JsonDelta => JsonSerializer.Deserialize<JsonDelta>(root.GetRawText(), options)!,
_ => throw new JsonException($"Unknown content type: {type}")
};
}
public override void Write(Utf8JsonWriter writer, ContentDelta value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, value.GetType(), options);
}
}
@@ -0,0 +1,34 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using AnthropicClient.Models;
namespace AnthropicClient.Json;
class EventDataConverter : JsonConverter<EventData>
{
public override EventData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var jsonDocument = JsonDocument.ParseValue(ref reader);
var root = jsonDocument.RootElement;
var type = root.GetProperty("type").GetString();
return type switch
{
EventType.Ping => JsonSerializer.Deserialize<PingEventData>(root.GetRawText(), options)!,
EventType.Error => JsonSerializer.Deserialize<ErrorEventData>(root.GetRawText(), options)!,
EventType.MessageStart => JsonSerializer.Deserialize<MessageStartEventData>(root.GetRawText(), options)!,
EventType.MessageDelta => JsonSerializer.Deserialize<MessageDeltaEventData>(root.GetRawText(), options)!,
EventType.MessageStop => JsonSerializer.Deserialize<MessageStopEventData>(root.GetRawText(), options)!,
EventType.ContentBlockStart => JsonSerializer.Deserialize<ContentStartEventData>(root.GetRawText(), options)!,
EventType.ContentBlockDelta => JsonSerializer.Deserialize<ContentDeltaEventData>(root.GetRawText(), options)!,
EventType.ContentBlockStop => JsonSerializer.Deserialize<ContentStopEventData>(root.GetRawText(), options)!,
_ => throw new JsonException($"Unknown content type: {type}")
};
}
public override void Write(Utf8JsonWriter writer, EventData value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, value.GetType(), options);
}
}
@@ -14,6 +14,8 @@ static class JsonSerializationOptions
new ContentConverter(),
new ToolChoiceConverter(),
new ErrorConverter(),
new EventDataConverter(),
new ContentDeltaConverter(),
},
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public record AnthropicEvent
{
public string Type { get; init; } = string.Empty;
public EventData Data { get; init; } = default!;
[JsonConstructor]
internal AnthropicEvent()
{
}
public AnthropicEvent(string type, EventData data)
{
Type = type;
Data = data;
}
}
@@ -1,7 +1,5 @@
using System.Text.Json.Serialization;
using AnthropicClient.Utils;
namespace AnthropicClient.Models;
/// <summary>
@@ -9,66 +7,8 @@ namespace AnthropicClient.Models;
/// </summary>
public class ChatMessageRequest : MessageRequest
{
/// <summary>
/// Gets the model ID to use for the request.
/// </summary>
public string Model { get; init; } = string.Empty;
/// <summary>
/// Gets the system ID to use for the request.
/// </summary>
public string? System { get; init; } = null;
/// <summary>
/// Gets the messages to send to the model.
/// </summary>
public List<ChatMessage> Messages { get; init; } = [];
/// <summary>
/// Gets the maximum number of tokens to generate.
/// </summary>
[JsonPropertyName("max_tokens")]
public int MaxTokens { get; init; } = 1024;
/// <summary>
/// Gets the metadata to include with the request.
/// </summary>
public Dictionary<string, object>? Metadata { get; init; } = null;
/// <summary>
/// Gets the prompt stop sequences.
/// </summary>
[JsonPropertyName("stop_sequences")]
public List<string> StopSequences { get; init; } = [];
/// <summary>
/// Gets the temperature to use for the request.
/// </summary>
public decimal Temperature { get; init; } = 0.0m;
/// <summary>
/// Gets the top-K value to use for the request.
/// </summary>
public int? TopK { get; init; } = null;
/// <summary>
/// Gets the top-P value to use for the request.
/// </summary>
public decimal? TopP { get; init; } = null;
/// <summary>
/// Gets the tool choice mode to use for the request.
/// </summary>
[JsonPropertyName("tool_choice")]
public ToolChoice? ToolChoice { get; init; } = null;
/// <summary>
/// Gets the tools to use for the request.
/// </summary>
public List<Tool>? Tools { get; init; } = null;
[JsonConstructor]
internal ChatMessageRequest() : base(false) { }
internal ChatMessageRequest() : base() { }
/// <summary>
/// Initializes a new instance of the <see cref="ChatMessageRequest"/> class.
@@ -100,40 +40,19 @@ public class ChatMessageRequest : MessageRequest
decimal? topP = null,
ToolChoice? toolChoice = null,
List<Tool>? tools = null
) : base(false)
) : base(
model,
messages,
maxTokens,
system,
metadata,
temperature,
topK,
topP,
toolChoice,
tools,
false
)
{
ArgumentValidator.ThrowIfNull(model, nameof(model));
ArgumentValidator.ThrowIfNull(messages, nameof(messages));
if (AnthropicModels.IsValidModel(model) is false)
{
throw new ArgumentException($"Invalid model ID: {model}");
}
if (messages.Count < 1)
{
throw new ArgumentException("Messages must contain at least one message");
}
if (maxTokens < 1)
{
throw new ArgumentException($"Invalid max tokens: {maxTokens}");
}
if (temperature < 0.0m || temperature > 1.0m)
{
throw new ArgumentException($"Invalid temperature: {temperature}");
}
Model = model;
Messages = messages;
MaxTokens = maxTokens;
System = system;
Metadata = metadata;
Temperature = temperature;
TopK = topK;
TopP = topP;
ToolChoice = toolChoice;
Tools = tools;
}
}
+8 -8
View File
@@ -10,42 +10,42 @@ public class ChatResponse
/// <summary>
/// Gets the ID of the chat response.
/// </summary>
public string Id { get; set; } = string.Empty;
public string Id { get; init; } = string.Empty;
/// <summary>
/// Gets the model used for the chat response.
/// </summary>
public string Model { get; set; } = string.Empty;
public string Model { get; init; } = string.Empty;
/// <summary>
/// Gets the role of the chat response.
/// </summary>
public string Role { get; set; } = string.Empty;
public string Role { get; init; } = string.Empty;
/// <summary>
/// Gets the stop reason of the chat response.
/// </summary>
[JsonPropertyName("stop_reason")]
public string StopReason { get; set; } = string.Empty;
public string StopReason { get; init; } = string.Empty;
/// <summary>
/// Gets the stop sequence of the chat response.
/// </summary>
[JsonPropertyName("stop_sequence")]
public string StopSequence { get; set; } = string.Empty;
public string StopSequence { get; init; } = string.Empty;
/// <summary>
/// Gets the type of the chat response.
/// </summary>
public string Type { get; set; } = string.Empty;
public string Type { get; init; } = string.Empty;
/// <summary>
/// Gets the usage of the chat response.
/// </summary>
public ChatUsage Usage { get; set; } = new();
public ChatUsage Usage { get; init; } = new();
/// <summary>
/// Gets the contents of the chat response.
/// </summary>
public List<Content> Content { get; set; } = [];
public List<Content> Content { get; init; } = [];
}
+2 -2
View File
@@ -11,11 +11,11 @@ public class ChatUsage
/// Gets the number of input tokens used.
/// </summary>
[JsonPropertyName("input_tokens")]
public int InputTokens { get; set; }
public int InputTokens { get; init; }
/// <summary>
/// Gets the number of output tokens used.
/// </summary>
[JsonPropertyName("output_tokens")]
public int OutputTokens { get; set; }
public int OutputTokens { get; init; }
}
@@ -0,0 +1,11 @@
namespace AnthropicClient.Models;
public abstract class ContentDelta
{
public string Type { get; init; }
protected ContentDelta(string type)
{
Type = type;
}
}
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public class ContentDeltaEventData : EventData
{
public int Index { get; init; }
public ContentDelta Delta { get; init; } = default!;
[JsonConstructor]
internal ContentDeltaEventData() : base(EventType.ContentBlockDelta)
{
}
public ContentDeltaEventData(int index, ContentDelta delta) : base(EventType.ContentBlockDelta)
{
Index = index;
Delta = delta;
}
}
@@ -0,0 +1,7 @@
namespace AnthropicClient.Models;
public static class ContentDeltaType
{
public const string TextDelta = "text_delta";
public const string JsonDelta = "input_json_delta";
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public class ContentStartEventData : EventData
{
public int Index { get; init; }
[JsonPropertyName("content_block")]
public Content ContentBlock { get; init; }
[JsonConstructor]
internal ContentStartEventData() : base(EventType.ContentBlockStart)
{
}
public ContentStartEventData(int index, Content contentBlock) : base(EventType.ContentBlockStart)
{
Index = index;
ContentBlock = contentBlock;
}
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public class ContentStopEventData : EventData
{
public int Index { get; init; }
[JsonConstructor]
internal ContentStopEventData() : base(EventType.ContentBlockStop)
{
}
public ContentStopEventData(int index) : base(EventType.ContentBlockStop)
{
Index = index;
}
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public class ErrorEventData : EventData
{
public Error Error { get; init; } = default!;
[JsonConstructor]
internal ErrorEventData() : base(EventType.Error)
{
}
public ErrorEventData(Error error) : base(EventType.Error)
{
Error = error;
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public abstract class EventData
{
public string Type { get; init; }
protected EventData(string type)
{
Type = type;
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace AnthropicClient.Models;
public static class EventType
{
public const string Error = "error";
public const string Ping = "ping";
public const string MessageStart = "message_start";
public const string MessageDelta = "message_delta";
public const string MessageStop = "message_stop";
public const string ContentBlockStart = "content_block_start";
public const string ContentBlockDelta = "content_block_delta";
public const string ContentBlockStop = "content_block_stop";
}
+19
View File
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public class JsonDelta : ContentDelta
{
[JsonPropertyName("partial_json")]
public string PartialJson { get; init; } = string.Empty;
[JsonConstructor]
internal JsonDelta() : base(ContentDeltaType.JsonDelta)
{
}
public JsonDelta(string partialJson) : base(ContentDeltaType.JsonDelta)
{
PartialJson = partialJson;
}
}
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public class MessageDeltaEventData : EventData
{
public MessageDelta Delta { get; init; }
public ChatUsage Usage { get; init; }
[JsonConstructor]
internal MessageDeltaEventData() : base(EventType.MessageDelta)
{
}
public MessageDeltaEventData(MessageDelta delta, ChatUsage usage) : base(EventType.MessageDelta)
{
Delta = delta;
Usage = usage;
}
}
public class MessageDelta
{
[JsonPropertyName("stop_reason")]
public string StopReason { get; init; } = string.Empty;
[JsonPropertyName("stop_sequence")]
public string StopSequence { get; init; } = string.Empty;
[JsonConstructor]
internal MessageDelta()
{
}
public MessageDelta(string stopReason, string stopSequence)
{
StopReason = stopReason;
StopSequence = stopSequence;
}
}
+126 -1
View File
@@ -1,3 +1,7 @@
using System.Text.Json.Serialization;
using AnthropicClient.Utils;
namespace AnthropicClient.Models;
/// <summary>
@@ -5,18 +9,139 @@ namespace AnthropicClient.Models;
/// </summary>
public abstract class MessageRequest
{
/// <summary>
/// Gets the model ID to use for the request.
/// </summary>
public string Model { get; init; } = string.Empty;
/// <summary>
/// Gets the system ID to use for the request.
/// </summary>
public string? System { get; init; } = null;
/// <summary>
/// Gets the messages to send to the model.
/// </summary>
public List<ChatMessage> Messages { get; init; } = [];
/// <summary>
/// Gets the maximum number of tokens to generate.
/// </summary>
[JsonPropertyName("max_tokens")]
public int MaxTokens { get; init; } = 1024;
/// <summary>
/// Gets the metadata to include with the request.
/// </summary>
public Dictionary<string, object>? Metadata { get; init; } = null;
/// <summary>
/// Gets the prompt stop sequences.
/// </summary>
[JsonPropertyName("stop_sequences")]
public List<string> StopSequences { get; init; } = [];
/// <summary>
/// Gets the temperature to use for the request.
/// </summary>
public decimal Temperature { get; init; } = 0.0m;
/// <summary>
/// Gets the top-K value to use for the request.
/// </summary>
public int? TopK { get; init; } = null;
/// <summary>
/// Gets the top-P value to use for the request.
/// </summary>
public decimal? TopP { get; init; } = null;
/// <summary>
/// Gets the tool choice mode to use for the request.
/// </summary>
[JsonPropertyName("tool_choice")]
public ToolChoice? ToolChoice { get; init; } = null;
/// <summary>
/// Gets the tools to use for the request.
/// </summary>
public List<Tool>? Tools { get; init; } = null;
/// <summary>
/// Gets a value indicating whether the message should be streamed.
/// </summary>
public bool Stream { get; init; }
[JsonConstructor]
internal MessageRequest() { }
/// <summary>
/// Initializes a new instance of the <see cref="MessageRequest"/> class.
/// </summary>
/// <param name="model">The model ID to use for the request.</param>
/// <param name="messages">The messages to send to the model.</param>
/// <param name="maxTokens">The maximum number of tokens to generate.</param>
/// <param name="system">The system ID to use for the request.</param>
/// <param name="metadata">The metadata to include with the request.</param>
/// <param name="temperature">The temperature to use for the request.</param>
/// <param name="topK">The top-K value to use for the request.</param>
/// <param name="topP">The top-P value to use for the request.</param>
/// <param name="toolChoice">The tool choice mode to use for the request.</param>
/// <param name="tools">The tools to use for the request.</param>
/// <param name="stream">A value indicating whether the message should be streamed.</param>
/// <exception cref="ArgumentException">Thrown when the model ID is invalid.</exception>
/// <exception cref="ArgumentNullException">Thrown when the model or messages is null.</exception>
/// <exception cref="ArgumentException">Thrown when the messages contain no messages.</exception>
/// <exception cref="ArgumentException">Thrown when the max tokens is less than one.</exception>
/// <exception cref="ArgumentException">Thrown when the temperature is less than zero or greater than one.</exception>
/// <returns>A new instance of the <see cref="MessageRequest"/> class.</returns>
public MessageRequest(bool stream = false)
protected MessageRequest(
string model,
List<ChatMessage> messages,
int maxTokens = 1024,
string? system = null,
Dictionary<string, object>? metadata = null,
decimal temperature = 0.0m,
int? topK = null,
decimal? topP = null,
ToolChoice? toolChoice = null,
List<Tool>? tools = null,
bool stream = false
)
{
ArgumentValidator.ThrowIfNull(model, nameof(model));
ArgumentValidator.ThrowIfNull(messages, nameof(messages));
if (AnthropicModels.IsValidModel(model) is false)
{
throw new ArgumentException($"Invalid model ID: {model}");
}
if (messages.Count < 1)
{
throw new ArgumentException("Messages must contain at least one message");
}
if (maxTokens < 1)
{
throw new ArgumentException($"Invalid max tokens: {maxTokens}");
}
if (temperature < 0.0m || temperature > 1.0m)
{
throw new ArgumentException($"Invalid temperature: {temperature}");
}
Model = model;
Messages = messages;
MaxTokens = maxTokens;
System = system;
Metadata = metadata;
Temperature = temperature;
TopK = topK;
TopP = topP;
ToolChoice = toolChoice;
Tools = tools;
Stream = stream;
}
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public class MessageStartEventData : EventData
{
public ChatMessage Message { get; init; } = new();
[JsonConstructor]
internal MessageStartEventData() : base(EventType.MessageStart)
{
}
public MessageStartEventData(ChatMessage message) : base(EventType.MessageStart)
{
Message = message;
}
}
@@ -0,0 +1,8 @@
namespace AnthropicClient.Models;
public class MessageStopEventData : EventData
{
public MessageStopEventData() : base(EventType.MessageStop)
{
}
}
@@ -0,0 +1,8 @@
namespace AnthropicClient.Models;
public class PingEventData : EventData
{
public PingEventData() : base(EventType.Ping)
{
}
}
@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
/// <summary>
/// Represents a chat message request.
/// </summary>
public class StreamChatMessageRequest : MessageRequest
{
[JsonConstructor]
internal StreamChatMessageRequest() : base() { }
/// <summary>
/// Initializes a new instance of the <see cref="StreamChatMessageRequest"/> class.
/// </summary>
/// <param name="model">The model ID to use for the request.</param>
/// <param name="messages">The messages to send to the model.</param>
/// <param name="maxTokens">The maximum number of tokens to generate.</param>
/// <param name="system">The system ID to use for the request.</param>
/// <param name="metadata">The metadata to include with the request.</param>
/// <param name="temperature">The temperature to use for the request.</param>
/// <param name="topK">The top-K value to use for the request.</param>
/// <param name="topP">The top-P value to use for the request.</param>
/// <param name="toolChoice">The tool choice mode to use for the request.</param>
/// <param name="tools">The tools to use for the request.</param>
/// <exception cref="ArgumentException">Thrown when the model ID is invalid.</exception>
/// <exception cref="ArgumentNullException">Thrown when the model or messages is null.</exception>
/// <exception cref="ArgumentException">Thrown when the messages contain no messages.</exception>
/// <exception cref="ArgumentException">Thrown when the max tokens is less than one.</exception>
/// <exception cref="ArgumentException">Thrown when the temperature is less than zero or greater than one.</exception>
/// <returns>A new instance of the <see cref="StreamChatMessageRequest"/> class.</returns>
public StreamChatMessageRequest(
string model,
List<ChatMessage> messages,
int maxTokens = 1024,
string? system = null,
Dictionary<string, object>? metadata = null,
decimal temperature = 0.0m,
int? topK = null,
decimal? topP = null,
ToolChoice? toolChoice = null,
List<Tool>? tools = null
) : base(
model,
messages,
maxTokens,
system,
metadata,
temperature,
topK,
topP,
toolChoice,
tools,
true
)
{
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace AnthropicClient.Models;
public class TextDelta : ContentDelta
{
public string Text { get; set; } = string.Empty;
[JsonConstructor]
internal TextDelta() : base(ContentDeltaType.TextDelta)
{
}
public TextDelta(string text) : base(ContentDeltaType.TextDelta)
{
Text = text;
}
}
@@ -13,12 +13,12 @@ public class ToolResultContent : Content
/// Gets the tool use ID of the content.
/// </summary>
[JsonPropertyName("tool_use_id")]
public string ToolUseId { get; set; } = string.Empty;
public string ToolUseId { get; init; } = string.Empty;
/// <summary>
/// Gets the content of the tool result.
/// </summary>
public string Content { get; set; } = string.Empty;
public string Content { get; init; } = string.Empty;
[JsonConstructor]
internal ToolResultContent() : base(ContentType.ToolResult) { }
+3 -3
View File
@@ -8,17 +8,17 @@ public class ToolUseContent : Content
/// <summary>
/// Gets the ID of the tool use.
/// </summary>
public string Id { get; set; } = string.Empty;
public string Id { get; init; } = string.Empty;
/// <summary>
/// Gets the name of the tool.
/// </summary>
public string Name { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty;
/// <summary>
/// Gets the input of the tool.
/// </summary>
public Dictionary<string, object?> Input { get; set; } = [];
public Dictionary<string, object?> Input { get; init; } = [];
/// <summary>
/// Initializes a new instance of the <see cref="ToolUseContent"/> class.
@@ -18,4 +18,24 @@ public class ClientTests(
result.IsSuccess.Should().BeTrue();
result.Value.Should().BeOfType<ChatResponse>();
}
[Fact]
public async Task CreateChatMessage_WhenCalledWithStreamRequest_IteratesOverChatResponse()
{
var request = new StreamChatMessageRequest(
model: AnthropicModels.Claude3Haiku,
messages: [new(MessageRole.User, [new TextContent("Hello!")])]
);
var response = _client.CreateChatMessageAsync(request);
var events = new List<AnthropicEvent>();
await foreach (var e in response)
{
events.Add(e);
}
events.Should().NotBeEmpty();
}
}