From 9548754d6cab73d21c7b7a28264374e79dcb8bab Mon Sep 17 00:00:00 2001
From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com>
Date: Wed, 8 Jan 2025 20:37:58 -0600
Subject: [PATCH] feat: implement CreateMessageBatchAsync method
---
src/AnthropicClient/AnthropicApiClient.cs | 27 +++-
.../Models/MessageBatchRequest.cs | 28 +++++
.../Models/MessageBatchRequestItem.cs | 39 ++++++
.../Models/MessageBatchResponse.cs | 98 +++++++++++++++
.../Integration/AnthropicApiClientTests.cs | 54 ++++++++
.../Integration/IntegrationTest.cs | 7 ++
.../Models/MessageBatchRequestItemTests.cs | 36 ++++++
.../Unit/Models/MessageBatchRequestTests.cs | 84 +++++++++++++
.../Unit/Models/MessageBatchResponseTests.cs | 115 ++++++++++++++++++
9 files changed, 487 insertions(+), 1 deletion(-)
create mode 100644 src/AnthropicClient/Models/MessageBatchRequest.cs
create mode 100644 src/AnthropicClient/Models/MessageBatchRequestItem.cs
create mode 100644 src/AnthropicClient/Models/MessageBatchResponse.cs
create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestItemTests.cs
create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestTests.cs
create mode 100644 tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs
diff --git a/src/AnthropicClient/AnthropicApiClient.cs b/src/AnthropicClient/AnthropicApiClient.cs
index 3be31ee..831161d 100644
--- a/src/AnthropicClient/AnthropicApiClient.cs
+++ b/src/AnthropicClient/AnthropicApiClient.cs
@@ -28,6 +28,13 @@ public interface IAnthropicApiClient
/// An asynchronous enumerable that yields the response event by event.
IAsyncEnumerable CreateMessageAsync(StreamMessageRequest request);
+ ///
+ /// Creates a batch of messages asynchronously.
+ ///
+ /// The message batch request to create.
+ /// A task that represents the asynchronous operation. The task result contains the response as an where T is .
+ Task> CreateMessageBatchAsync(MessageBatchRequest request);
+
///
/// Counts the tokens in a message asynchronously.
///
@@ -64,7 +71,8 @@ public class AnthropicApiClient : IAnthropicApiClient
private const string BaseUrl = "https://api.anthropic.com/v1/";
private const string ApiKeyHeader = "x-api-key";
private const string MessagesEndpoint = "messages";
- private const string CountTokensEndpoint = "messages/count_tokens";
+ private string CountTokensEndpoint => $"{MessagesEndpoint}/count-tokens";
+ private string MessageBatchesEndpoint => $"{MessagesEndpoint}/batches";
private const string ModelsEndpoint = "models";
private const string JsonContentType = "application/json";
private const string EventPrefix = "event:";
@@ -287,6 +295,23 @@ public class AnthropicApiClient : IAnthropicApiClient
} while (true);
}
+ ///
+ public async Task> CreateMessageBatchAsync(MessageBatchRequest request)
+ {
+ var response = await SendRequestAsync(MessageBatchesEndpoint, request);
+ var anthropicHeaders = new AnthropicHeaders(response.Headers);
+ var responseContent = await response.Content.ReadAsStringAsync();
+
+ if (response.IsSuccessStatusCode is false)
+ {
+ var error = Deserialize(responseContent) ?? new AnthropicError();
+ return AnthropicResult.Failure(error, anthropicHeaders);
+ }
+
+ var msgBatchResponse = Deserialize(responseContent) ?? new MessageBatchResponse();
+ return AnthropicResult.Success(msgBatchResponse, anthropicHeaders);
+ }
+
///
public async Task> CountMessageTokensAsync(CountMessageTokensRequest request)
{
diff --git a/src/AnthropicClient/Models/MessageBatchRequest.cs b/src/AnthropicClient/Models/MessageBatchRequest.cs
new file mode 100644
index 0000000..0af5514
--- /dev/null
+++ b/src/AnthropicClient/Models/MessageBatchRequest.cs
@@ -0,0 +1,28 @@
+namespace AnthropicClient.Models;
+
+///
+/// Represents a request to create a batch of messages.
+///
+public class MessageBatchRequest
+{
+ ///
+ /// Gets the requests to create messages.
+ ///
+ public List Requests { get; init; } = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The requests to create messages.
+ /// Thrown when is empty.
+ /// An instance of the class.
+ public MessageBatchRequest(List requests)
+ {
+ if (requests.Count == 0)
+ {
+ throw new ArgumentException($"{nameof(requests)} must not be empty.", nameof(requests));
+ }
+
+ Requests = requests;
+ }
+}
\ No newline at end of file
diff --git a/src/AnthropicClient/Models/MessageBatchRequestItem.cs b/src/AnthropicClient/Models/MessageBatchRequestItem.cs
new file mode 100644
index 0000000..aec4aee
--- /dev/null
+++ b/src/AnthropicClient/Models/MessageBatchRequestItem.cs
@@ -0,0 +1,39 @@
+using System.Text.Json.Serialization;
+
+using AnthropicClient.Utils;
+
+namespace AnthropicClient.Models;
+
+///
+/// Represents an item in a batch of messages.
+///
+public class MessageBatchRequestItem
+{
+ ///
+ /// Gets the custom identifier for the message.
+ ///
+ [JsonPropertyName("custom_id")]
+ public string CustomId { get; init; }
+
+ ///
+ /// Gets the message request parameters.
+ ///
+ public MessageRequest Params { get; init; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The custom identifier for the message.
+ /// The message request parameters.
+ /// Thrown when is null or whitespace.
+ /// Thrown when is null.
+ /// An instance of the class.
+ public MessageBatchRequestItem(string customId, MessageRequest messageRequest)
+ {
+ ArgumentValidator.ThrowIfNullOrWhitespace(customId, nameof(customId));
+ ArgumentValidator.ThrowIfNull(messageRequest, nameof(messageRequest));
+
+ CustomId = customId;
+ Params = messageRequest;
+ }
+}
\ No newline at end of file
diff --git a/src/AnthropicClient/Models/MessageBatchResponse.cs b/src/AnthropicClient/Models/MessageBatchResponse.cs
new file mode 100644
index 0000000..b3c0a0e
--- /dev/null
+++ b/src/AnthropicClient/Models/MessageBatchResponse.cs
@@ -0,0 +1,98 @@
+using System.Text.Json.Serialization;
+
+namespace AnthropicClient.Models;
+
+///
+/// Represents a response to a batch of messages.
+///
+public class MessageBatchResponse
+{
+ ///
+ /// Gets the identifier of the batch.
+ ///
+ public string Id { get; init; } = string.Empty;
+
+ ///
+ /// Gets the type of the batch.
+ ///
+ public string Type { get; init; } = string.Empty;
+
+ ///
+ /// Gets the processing status of the batch.
+ ///
+ [JsonPropertyName("processing_status")]
+ public string ProcessingStatus { get; init; } = string.Empty;
+
+ ///
+ /// Gets the counts of requests in the batch.
+ ///
+ [JsonPropertyName("request_counts")]
+ public MessageBatchRequestCounts RequestCounts { get; init; } = new MessageBatchRequestCounts();
+
+ ///
+ /// Gets the date and time when the batch ended.
+ ///
+ [JsonPropertyName("ended_at")]
+ public DateTimeOffset EndedAt { get; init; }
+
+ ///
+ /// Gets the date and time when the batch was created.
+ ///
+ [JsonPropertyName("created_at")]
+ public DateTimeOffset CreatedAt { get; init; }
+
+ ///
+ /// Gets the date and time when the batch expires.
+ ///
+ [JsonPropertyName("expires_at")]
+ public DateTimeOffset ExpiresAt { get; init; }
+
+ ///
+ /// Gets the date and time when the batch was archived.
+ ///
+ [JsonPropertyName("archived_at")]
+ public DateTimeOffset ArchivedAt { get; init; }
+
+ ///
+ /// Gets the date and time when the batch cancellation was initiated.
+ ///
+ [JsonPropertyName("cancel_initiated_at")]
+ public DateTimeOffset CancelInitiatedAt { get; init; }
+
+ ///
+ /// Gets the URL to the results of the batch.
+ ///
+ [JsonPropertyName("results_url")]
+ public string ResultsUrl { get; init; } = string.Empty;
+}
+
+///
+/// Represents the counts of requests in a batch of messages.
+///
+public class MessageBatchRequestCounts
+{
+ ///
+ /// Gets the number of requests in the batch that are processing.
+ ///
+ public int Processing { get; init; }
+
+ ///
+ /// Gets the number of requests in the batch that succeeded.
+ ///
+ public int Succeeded { get; init; }
+
+ ///
+ /// Gets the number of requests in the batch that errored.
+ ///
+ public int Errored { get; init; }
+
+ ///
+ /// Gets the number of requests in the batch that were cancelled.
+ ///
+ public int Canceled { get; init; }
+
+ ///
+ /// Gets the number of requests in the batch that expired.
+ ///
+ public int Expired { get; init; }
+}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs
index eaef353..ca97570 100644
--- a/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs
+++ b/tests/AnthropicClient.Tests/Integration/AnthropicApiClientTests.cs
@@ -978,4 +978,58 @@ public class AnthropicApiClientTests : IntegrationTest
result.IsSuccess.Should().BeTrue();
result.Value.Should().BeOfType();
}
+
+ [Fact]
+ public async Task CreateMessageBatchAsync_WhenCalled_ItShouldReturnBatch()
+ {
+ _mockHttpMessageHandler
+ .WhenCreateMessageBatchRequest()
+ .Respond(
+ HttpStatusCode.OK,
+ "application/json",
+ @"{
+ ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"",
+ ""type"": ""message_batch"",
+ ""processing_status"": ""in_progress"",
+ ""request_counts"": {
+ ""processing"": 100,
+ ""succeeded"": 50,
+ ""errored"": 30,
+ ""canceled"": 10,
+ ""expired"": 10
+ },
+ ""ended_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""created_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""expires_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""archived_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results""
+ }"
+ );
+
+ var request = new MessageBatchRequest([new("custom_id", new())]);
+
+ var result = await Client.CreateMessageBatchAsync(request);
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().BeOfType();
+ result.Value.Id.Should().Be("msgbatch_013Zva2CMHLNnXjNJJKqJ2EF");
+ result.Value.Type.Should().Be("message_batch");
+ result.Value.ProcessingStatus.Should().Be("in_progress");
+ result.Value.RequestCounts.Should().BeEquivalentTo(new MessageBatchRequestCounts
+ {
+ Processing = 100,
+ Succeeded = 50,
+ Errored = 30,
+ Canceled = 10,
+ Expired = 10
+ });
+
+ result.Value.EndedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"));
+ result.Value.CreatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"));
+ result.Value.ExpiresAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"));
+ result.Value.ArchivedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"));
+ result.Value.CancelInitiatedAt.Should().Be(DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"));
+ result.Value.ResultsUrl.Should().Be("https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results");
+ }
}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs
index 6b6376d..1a9ef1c 100644
--- a/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs
+++ b/tests/AnthropicClient.Tests/Integration/IntegrationTest.cs
@@ -16,6 +16,7 @@ public static class MockHttpMessageHandlerExtensions
private const string BaseUrl = "https://api.anthropic.com/v1";
private static readonly string MessagesEndpoint = $"{BaseUrl}/messages";
private static readonly string CountTokensEndpoint = $"{BaseUrl}/messages/count_tokens";
+ private static readonly string MessageBatchesEndpoint = $"{BaseUrl}/messages/batches";
private static readonly string ModelsEndpoint = $"{BaseUrl}/models";
private static MockedRequest SetupBaseRequest(
@@ -64,4 +65,10 @@ public static class MockHttpMessageHandlerExtensions
return mockHttpMessageHandler
.SetupBaseRequest(HttpMethod.Get, $"{ModelsEndpoint}/{modelId}");
}
+
+ public static MockedRequest WhenCreateMessageBatchRequest(this MockHttpMessageHandler mockHttpMessageHandler)
+ {
+ return mockHttpMessageHandler
+ .SetupBaseRequest(HttpMethod.Post, MessageBatchesEndpoint);
+ }
}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestItemTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestItemTests.cs
new file mode 100644
index 0000000..2f14087
--- /dev/null
+++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestItemTests.cs
@@ -0,0 +1,36 @@
+namespace AnthropicClient.Tests.Unit.Models;
+
+public class MessageBatchRequestItemTests : SerializationTest
+{
+ [Fact]
+ public void Constructor_WhenCalled_ItShouldReturnInstanceWithPropertiesSet()
+ {
+ var customId = "custom_id";
+ var messageRequest = new MessageRequest();
+
+ var result = new MessageBatchRequestItem(customId, messageRequest);
+
+ result.Should().BeOfType();
+ result.CustomId.Should().Be(customId);
+ result.Params.Should().BeSameAs(messageRequest);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Constructor_WhenCalledAndCustomIdIsInvalid_ItShouldThrowException(string? customId)
+ {
+ var act = () => new MessageBatchRequestItem(customId!, new MessageRequest());
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_WhenCalledAndMessageRequestIsNull_ItShouldThrowException()
+ {
+ var act = () => new MessageBatchRequestItem("custom_id", null!);
+
+ act.Should().Throw();
+ }
+}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestTests.cs
new file mode 100644
index 0000000..ccd2691
--- /dev/null
+++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchRequestTests.cs
@@ -0,0 +1,84 @@
+namespace AnthropicClient.Tests.Unit.Models;
+
+public class MessageBatchRequestTests : SerializationTest
+{
+ private const string SampleJson = @"{
+ ""requests"": [
+ {
+ ""custom_id"": ""my-first-request"",
+ ""params"": {
+ ""model"": ""claude-3-5-sonnet-20241022"",
+ ""messages"": [
+ {""role"": ""user"", ""content"": [{ ""text"": ""Hello, world"", ""type"": ""text"" }]}
+ ],
+ ""max_tokens"": 1024,
+ ""stop_sequences"": [],
+ ""temperature"": 0.0,
+ ""stream"": false
+ }
+ },
+ {
+ ""custom_id"": ""my-second-request"",
+ ""params"": {
+ ""model"": ""claude-3-5-sonnet-20241022"",
+ ""messages"": [
+ {""role"": ""user"", ""content"": [{ ""text"": ""Hi again, friend"", ""type"": ""text"" }]}
+ ],
+ ""max_tokens"": 1024,
+ ""stop_sequences"": [],
+ ""temperature"": 0.0,
+ ""stream"": false
+ }
+ }
+ ]
+ }";
+
+ [Fact]
+ public void Constructor_WhenCalled_ItShouldReturnInstanceWithPropertiesSet()
+ {
+ var requests = new List { new("custom_id", new()) };
+
+ var result = new MessageBatchRequest(requests);
+
+ result.Should().BeOfType();
+ result.Requests.Should().BeSameAs(requests);
+ }
+
+ [Fact]
+ public void Constructor_WhenCalledWithEmptyRequests_ItShouldThrowException()
+ {
+ var act = () => new MessageBatchRequest([]);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape()
+ {
+ var requests = new List
+ {
+ new("my-first-request", new()
+ {
+ Model = "claude-3-5-sonnet-20241022",
+ MaxTokens = 1024,
+ Messages = [
+ new() { Role = "user", Content = [new TextContent("Hello, world")] }
+ ]
+ }),
+ new("my-second-request", new()
+ {
+ Model = "claude-3-5-sonnet-20241022",
+ MaxTokens = 1024,
+ Messages = [
+ new() { Role = "user", Content = [new TextContent("Hi again, friend")] }
+ ]
+ })
+ };
+
+ var result = new MessageBatchRequest(requests);
+
+ var json = Serialize(result);
+
+ JsonAssert.Equal(SampleJson, json);
+ }
+}
\ No newline at end of file
diff --git a/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs
new file mode 100644
index 0000000..4cde6a1
--- /dev/null
+++ b/tests/AnthropicClient.Tests/Unit/Models/MessageBatchResponseTests.cs
@@ -0,0 +1,115 @@
+namespace AnthropicClient.Tests.Unit.Models;
+
+public class MessageBatchResponseTests : SerializationTest
+{
+ private const string SampleJson = @"{
+ ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"",
+ ""type"": ""message_batch"",
+ ""processing_status"": ""in_progress"",
+ ""request_counts"": {
+ ""processing"": 100,
+ ""succeeded"": 50,
+ ""errored"": 30,
+ ""canceled"": 10,
+ ""expired"": 10
+ },
+ ""ended_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""created_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""expires_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""archived_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435Z"",
+ ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results""
+ }";
+
+
+ [Fact]
+ public void Constructor_WhenCalled_ItShouldReturnInstanceWithPropertiesSet()
+ {
+ var result = new MessageBatchResponse();
+
+ result.Should().BeOfType();
+ result.Id.Should().BeEmpty();
+ result.Type.Should().BeEmpty();
+ result.ProcessingStatus.Should().BeEmpty();
+ result.RequestCounts.Should().BeEquivalentTo(new MessageBatchRequestCounts());
+ result.EndedAt.Should().Be(DateTimeOffset.MinValue);
+ result.CreatedAt.Should().Be(DateTimeOffset.MinValue);
+ result.ExpiresAt.Should().Be(DateTimeOffset.MinValue);
+ result.ArchivedAt.Should().Be(DateTimeOffset.MinValue);
+ result.CancelInitiatedAt.Should().Be(DateTimeOffset.MinValue);
+ result.ResultsUrl.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void JsonSerialization_WhenDeserialized_ItShouldHaveExpectedValues()
+ {
+ var result = Deserialize(SampleJson);
+
+ result.Should().BeEquivalentTo(new MessageBatchResponse
+ {
+ Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF",
+ Type = "message_batch",
+ ProcessingStatus = "in_progress",
+ RequestCounts = new MessageBatchRequestCounts
+ {
+ Processing = 100,
+ Succeeded = 50,
+ Errored = 30,
+ Canceled = 10,
+ Expired = 10
+ },
+ EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"
+ });
+ }
+
+ [Fact]
+ public void JsonSerialization_WhenSerialized_ItShouldHaveExpectedShape()
+ {
+ var expectedJson = @"{
+ ""id"": ""msgbatch_013Zva2CMHLNnXjNJJKqJ2EF"",
+ ""type"": ""message_batch"",
+ ""processing_status"": ""in_progress"",
+ ""request_counts"": {
+ ""processing"": 100,
+ ""succeeded"": 50,
+ ""errored"": 30,
+ ""canceled"": 10,
+ ""expired"": 10
+ },
+ ""ended_at"": ""2024-08-20T18:37:24.100435+00:00"",
+ ""created_at"": ""2024-08-20T18:37:24.100435+00:00"",
+ ""expires_at"": ""2024-08-20T18:37:24.100435+00:00"",
+ ""archived_at"": ""2024-08-20T18:37:24.100435+00:00"",
+ ""cancel_initiated_at"": ""2024-08-20T18:37:24.100435+00:00"",
+ ""results_url"": ""https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results""
+ }";
+
+ var result = Serialize(new MessageBatchResponse
+ {
+ Id = "msgbatch_013Zva2CMHLNnXjNJJKqJ2EF",
+ Type = "message_batch",
+ ProcessingStatus = "in_progress",
+ RequestCounts = new MessageBatchRequestCounts
+ {
+ Processing = 100,
+ Succeeded = 50,
+ Errored = 30,
+ Canceled = 10,
+ Expired = 10
+ },
+ EndedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ CreatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ ExpiresAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ ArchivedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ CancelInitiatedAt = DateTimeOffset.Parse("2024-08-20T18:37:24.100435Z"),
+ ResultsUrl = "https://api.anthropic.com/v1/messages/batches/msgbatch_013Zva2CMHLNnXjNJJKqJ2EF/results"
+ });
+
+ JsonAssert.Equal(expectedJson, result);
+ }
+}
\ No newline at end of file