diff --git a/README.md b/README.md index 7520db89..44990c93 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,7 @@ # Whetstone.ChatGPT -A simple light-weight library that wraps ChatGPT API completions. - -This library includes quality of life improvements: - -- including support for __CancellationTokens__ -- documentation comments -- conversions of Unix Epoch time to DateTime, etc. -- __IChatGPTClient__ interface for use with dependency injection -- Streaming completion responses and fine tune events +A simple light-weight library that wraps ChatGPT API completions with support for dependency injection. Supported features include: @@ -21,6 +13,35 @@ Supported features include: - Images - Embeddings - Moderations +- Response streaming + +[Examples](https://github.com/johniwasz/whetstone.chatgpt/tree/main/src/examples) include: + +- Command line bot +- Azure Function Twitter Webhook that responds to DMs + +## Dependency Injection Quickstart + +```C# +services.Configure(options => +{ + options.ApiKey = "YOURAPIKEY"; + options.Organization = "YOURORGANIZATIONID"; +}); +``` + +Use: +```C# +services.AddHttpClient(); +``` +OR: +```C# +services.AddHttpClient(); +``` +Configure `IChatGPTClient` service: +```C# +services.AddScoped(); +``` ## Completion diff --git a/src/Whetstone.ChatGPT.Test/ImageTests.cs b/src/Whetstone.ChatGPT.Test/ImageTests.cs index b4a77aa3..6e0a94b3 100644 --- a/src/Whetstone.ChatGPT.Test/ImageTests.cs +++ b/src/Whetstone.ChatGPT.Test/ImageTests.cs @@ -16,12 +16,12 @@ public async Task GenerateImageAsync() ChatGPTCreateImageRequest imageRequest = new() { Prompt = "A sail boat", - Size = CreatedImageSize.Size1024, + Size = CreatedImageSize.Size256, ResponseFormat = CreatedImageFormat.Url }; - using (ChatGPTClient client = (ChatGPTClient)ChatGPTTestUtilties.GetClient()) + using (IChatGPTClient client = ChatGPTTestUtilties.GetClient()) { ChatGPTImageResponse? imageResponse = await client.CreateImageAsync(imageRequest); @@ -43,9 +43,6 @@ public async Task GenerateImageAsync() Assert.True(imageBytes.Length > 0); File.WriteAllBytes("sailboat.png", imageBytes); - - - } } diff --git a/src/Whetstone.ChatGPT/ChatGPTClient.cs b/src/Whetstone.ChatGPT/ChatGPTClient.cs index 91b708d2..7f9c1e47 100644 --- a/src/Whetstone.ChatGPT/ChatGPTClient.cs +++ b/src/Whetstone.ChatGPT/ChatGPTClient.cs @@ -26,16 +26,18 @@ namespace Whetstone.ChatGPT; /// public class ChatGPTClient : IChatGPTClient { - private const string ResponseLinePrefix = "data: "; + private const string ResponseLinePrefix = "data: "; private readonly HttpClient _client; private readonly bool _isHttpClientProvided = true; + private readonly ChatGPTCredentials _chatCredentials; + private bool _isDisposed; #region Constructors - + /// /// Creates a new instance of the class. /// @@ -45,7 +47,7 @@ public class ChatGPTClient : IChatGPTClient { } - + /// /// Creates a new instance of the class. /// @@ -69,15 +71,19 @@ public class ChatGPTClient : IChatGPTClient /// /// Creates a new instance of the class. /// - /// Supplies the GPT-3 API key and the organization. The organization is only needed if the caller belongs to more than one organziation. See Requesting Organization. - /// This HttpClient will be used to make requests to the GPT-3 API. The caller is responsible for disposing the HttpClient instance. + /// Supplies the GPT-3 API key and the organization. The organization is only needed if the caller belongs to more than one organziation. See Requesting Organization. /// API Key is required. public ChatGPTClient(IOptions credentialsOptions) : this(credentials: credentialsOptions.Value, httpClient: new HttpClient()) { } - - public ChatGPTClient(IOptions credentialsOptions, HttpClient client) : this(credentials: credentialsOptions.Value, httpClient: client) + /// + /// Creates a new instance of the class. + /// + /// Supplies the GPT-3 API key and the organization. The organization is only needed if the caller belongs to more than one organziation. See Requesting Organization. + /// This HttpClient will be used to make requests to the GPT-3 API. The caller is responsible for disposing the HttpClient instance. + /// API Key is required. + public ChatGPTClient(IOptions credentialsOptions, HttpClient httpClient) : this(credentials: credentialsOptions.Value, httpClient: httpClient) { } @@ -89,7 +95,7 @@ public ChatGPTClient(IOptions credentialsOptions, HttpClient /// API Key is required. private ChatGPTClient(ChatGPTCredentials credentials, HttpClient httpClient) { - if(credentials is null) + if (credentials is null) { throw new ArgumentNullException(nameof(credentials)); } @@ -99,6 +105,8 @@ private ChatGPTClient(ChatGPTCredentials credentials, HttpClient httpClient) throw new ArgumentException("ApiKey preoperty cannot be null or whitespace.", nameof(credentials)); } + _chatCredentials = credentials; + if (httpClient is null) { _client = new HttpClient(); @@ -110,11 +118,11 @@ private ChatGPTClient(ChatGPTCredentials credentials, HttpClient httpClient) _isHttpClientProvided = true; } - InitializeClient(_client, credentials); + InitializeClient(_client); } - private void InitializeClient(HttpClient client, ChatGPTCredentials creds) + private void InitializeClient(HttpClient client) { client.BaseAddress = new Uri("https://api.openai.com/v1/"); @@ -123,13 +131,6 @@ private void InitializeClient(HttpClient client, ChatGPTCredentials creds) { throw new ArgumentException("HttpClient already has authorization token.", nameof(client)); } - - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", creds.ApiKey); - - if (!string.IsNullOrWhiteSpace(creds.Organization)) - { - client.DefaultRequestHeaders.Add("OpenAI-Organization", creds.Organization); - } } #endregion @@ -208,64 +209,58 @@ private void InitializeClient(HttpClient client, ChatGPTCredentials creds) completionRequest.Stream = true; - - using (HttpRequestMessage httpReq = new HttpRequestMessage(HttpMethod.Post, "completions")) - { + using HttpRequestMessage httpReq = CreateRequestMessage(HttpMethod.Post, "completions"); - string requestString = JsonSerializer.Serialize(completionRequest); + CancellationToken cancelToken = cancellationToken ?? CancellationToken.None; - httpReq.Content = new StringContent(requestString, Encoding.UTF8, "application/json"); + string requestString = JsonSerializer.Serialize(completionRequest); - var responseMessage = cancellationToken is null ? - await _client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false) : - await _client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, cancellationToken.Value).ConfigureAwait(false); + httpReq.Content = new StringContent(requestString, Encoding.UTF8, "application/json"); - - if (responseMessage.IsSuccessStatusCode) - { + HttpResponseMessage responseMessage = await _client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, cancelToken).ConfigureAwait(false); + + + if (responseMessage.IsSuccessStatusCode) + { #if NETSTANDARD2_1 - var responseStream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false); + using Stream responseStream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false); #else - var responseStream = cancellationToken is null ? - await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false) : - await responseMessage.Content.ReadAsStreamAsync(cancellationToken.Value).ConfigureAwait(false); + using Stream responseStream = await responseMessage.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false); #endif - using var reader = new StreamReader(responseStream); - string? line = null; + using StreamReader reader = new(responseStream); + string? line = null; - while ((line = await reader.ReadLineAsync()) is not null) - { - - if (line.StartsWith(ResponseLinePrefix, System.StringComparison.OrdinalIgnoreCase)) - line = line.Substring(ResponseLinePrefix.Length); + while ((line = await reader.ReadLineAsync()) is not null) + { + + if (line.StartsWith(ResponseLinePrefix, System.StringComparison.OrdinalIgnoreCase)) + line = line.Substring(ResponseLinePrefix.Length); - if (!string.IsNullOrWhiteSpace(line) && line != "[DONE]") - { - ChatGPTCompletionResponse? streamedResponse = JsonSerializer.Deserialize(line.Trim()); - yield return streamedResponse; - } + if (!string.IsNullOrWhiteSpace(line) && line != "[DONE]") + { + ChatGPTCompletionResponse? streamedResponse = JsonSerializer.Deserialize(line.Trim()); + yield return streamedResponse; } } - else - { + } + else + { #if NETSTANDARD2_1 - string? responseString = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); + string? responseString = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); #else - string? responseString = cancellationToken is null ? - await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : - await responseMessage.Content.ReadAsStringAsync(cancellationToken.Value).ConfigureAwait(false); + string? responseString = await responseMessage.Content.ReadAsStringAsync(cancelToken).ConfigureAwait(false); #endif - ChatGPTErrorResponse? errResponse = JsonSerializer.Deserialize(responseString); - throw new ChatGPTException(errResponse?.Error, responseMessage.StatusCode); - } + ChatGPTErrorResponse? errResponse = JsonSerializer.Deserialize(responseString); + throw new ChatGPTException(errResponse?.Error, responseMessage.StatusCode); + } } -#endregion Completions + #endregion Completions -#region Edits + #region Edits /// public async Task CreateEditAsync(ChatGPTCreateEditRequest createEditRequest, CancellationToken? cancellationToken = null) { @@ -287,9 +282,9 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : return await SendRequestAsync(HttpMethod.Post, "edits", createEditRequest, cancellationToken).ConfigureAwait(false); } -#endregion + #endregion -#region File Operations + #region File Operations /// public async Task UploadFileAsync(ChatGPTUploadFileRequest? fileRequest, CancellationToken? cancellationToken = null) @@ -326,7 +321,7 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : { new ByteArrayContent(fileRequest.File.Content), "file", fileRequest.File.FileName } }; - using (var httpReq = new HttpRequestMessage(HttpMethod.Post, "files")) + using (HttpRequestMessage httpReq = CreateRequestMessage(HttpMethod.Post, "files")) { httpReq.Content = formContent; @@ -377,49 +372,41 @@ await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false)) throw new ArgumentException("Cannot be null or whitespace.", nameof(fileId)); } - using (var httpReq = new HttpRequestMessage(HttpMethod.Get, $"files/{fileId}/content")) - { - using (HttpResponseMessage? httpResponse = cancellationToken is null ? - await _client.SendAsync(httpReq) : - await _client.SendAsync(httpReq, cancellationToken.Value)) - { + CancellationToken cancelToken = cancellationToken ?? CancellationToken.None; - if (httpResponse.IsSuccessStatusCode) - { + using HttpRequestMessage httpReq = CreateRequestMessage(HttpMethod.Get, $"files/{fileId}/content"); - fileContent = new(); + using HttpResponseMessage? httpResponse = await _client.SendAsync(httpReq, cancelToken).ConfigureAwait(false); + if (httpResponse.IsSuccessStatusCode) + { + + fileContent = new(); #if NETSTANDARD2_1 - fileContent.Content = await httpResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + fileContent.Content = await httpResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false); #else - fileContent.Content = cancellationToken is null ? - await httpResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false) : - await httpResponse.Content.ReadAsByteArrayAsync(cancellationToken.Value).ConfigureAwait(false); + fileContent.Content = await httpResponse.Content.ReadAsByteArrayAsync(cancelToken).ConfigureAwait(false); #endif - fileContent.FileName = httpResponse.Content?.Headers?.ContentDisposition?.FileName?.Replace(@"""", ""); + fileContent.FileName = httpResponse.Content?.Headers?.ContentDisposition?.FileName?.Replace(@"""", ""); - return fileContent; - } - else - { - - string responseString = await GetResponseStringAsync(httpResponse, cancellationToken).ConfigureAwait(false); - - ChatGPTErrorResponse? errResponse = JsonSerializer.Deserialize(responseString); - throw new ChatGPTException(errResponse?.Error, httpResponse.StatusCode); - } - } + return fileContent; } + else + { + string responseString = await GetResponseStringAsync(httpResponse, cancelToken).ConfigureAwait(false); + ChatGPTErrorResponse? errResponse = JsonSerializer.Deserialize(responseString); + throw new ChatGPTException(errResponse?.Error, httpResponse.StatusCode); + } } -#endregion + #endregion -#region Fine Tunes + #region Fine Tunes /// - public async Task CreateFineTuneAsync(ChatGPTCreateFineTuneRequest? createFineTuneRequest, CancellationToken? cancellationToken = null) + public async Task CreateFineTuneAsync(ChatGPTCreateFineTuneRequest? createFineTuneRequest, CancellationToken? cancellationToken = null) { if (createFineTuneRequest is null) { @@ -476,72 +463,65 @@ await httpResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false) : { throw new ArgumentException("Cannot be null or whitespace.", nameof(fineTuneId)); } - + return await SendRequestAsync>(HttpMethod.Get, $"fine-tunes/{fineTuneId}/events", cancellationToken).ConfigureAwait(false); } - + /// public async IAsyncEnumerable StreamFineTuneEventsAsync(string? fineTuneId, CancellationToken? cancellationToken = null) { - + if (string.IsNullOrWhiteSpace(fineTuneId)) { throw new ArgumentException("Cannot be null or whitespace.", nameof(fineTuneId)); } - using (HttpRequestMessage httpReq = new HttpRequestMessage(HttpMethod.Get, $"fine-tunes/{fineTuneId}/events?stream=true")) - { + CancellationToken cancelToken = cancellationToken ?? CancellationToken.None; - var responseMessage = cancellationToken is null ? - await _client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false) : - await _client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, cancellationToken.Value).ConfigureAwait(false); + using HttpRequestMessage httpReq = CreateRequestMessage(HttpMethod.Get, $"fine-tunes/{fineTuneId}/events?stream=true"); + HttpResponseMessage responseMessage = await _client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, cancelToken).ConfigureAwait(false); - if (responseMessage.IsSuccessStatusCode) - { + if (responseMessage.IsSuccessStatusCode) + { #if NETSTANDARD2_1 - var responseStream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false); + using Stream responseStream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false); #else - var responseStream = cancellationToken is null ? - await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false) : - await responseMessage.Content.ReadAsStreamAsync(cancellationToken.Value).ConfigureAwait(false); + using Stream responseStream = await responseMessage.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false); #endif - using var reader = new StreamReader(responseStream); - string? line = null; - + using StreamReader reader = new(responseStream); + string? line = null; - while ((line = await reader.ReadLineAsync()) is not null) - { + while ((line = await reader.ReadLineAsync()) is not null) + { - if (line.StartsWith(ResponseLinePrefix, System.StringComparison.OrdinalIgnoreCase)) - line = line.Substring(ResponseLinePrefix.Length); + if (line.StartsWith(ResponseLinePrefix, System.StringComparison.OrdinalIgnoreCase)) + line = line.Substring(ResponseLinePrefix.Length); - if (!string.IsNullOrWhiteSpace(line) && line != "[DONE]") - { - ChatGPTEvent? streamedResponse = JsonSerializer.Deserialize(line.Trim()); - yield return streamedResponse; - } + if (!string.IsNullOrWhiteSpace(line) && line != "[DONE]") + { + ChatGPTEvent? streamedResponse = JsonSerializer.Deserialize(line.Trim()); + yield return streamedResponse; } } - else - { + } + else + { #if NETSTANDARD2_1 string? responseString = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); #else - string? responseString = cancellationToken is null ? - await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : - await responseMessage.Content.ReadAsStringAsync(cancellationToken.Value).ConfigureAwait(false); + string? responseString = await responseMessage.Content.ReadAsStringAsync(cancelToken).ConfigureAwait(false); #endif - ChatGPTErrorResponse? errResponse = JsonSerializer.Deserialize(responseString); - throw new ChatGPTException(errResponse?.Error, responseMessage.StatusCode); - } + ChatGPTErrorResponse? errResponse = JsonSerializer.Deserialize(responseString); + throw new ChatGPTException(errResponse?.Error, responseMessage.StatusCode); } + } -#endregion + #endregion /// public async Task CreateModerationAsync(ChatGPTCreateModerationRequest? createModerationRequest, CancellationToken? cancellationToken = null) @@ -578,7 +558,7 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : throw new ArgumentException($"Inputs cannot be null", nameof(createEmbeddingsRequest)); } - if(string.IsNullOrWhiteSpace(createEmbeddingsRequest.Model)) + if (string.IsNullOrWhiteSpace(createEmbeddingsRequest.Model)) { throw new ArgumentException($"Model cannot be null or empty", nameof(createEmbeddingsRequest)); } @@ -596,7 +576,7 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : throw new ArgumentNullException(nameof(createImageRequest)); } - if(string.IsNullOrWhiteSpace(createImageRequest.Prompt)) + if (string.IsNullOrWhiteSpace(createImageRequest.Prompt)) { throw new ArgumentException($"Prompt cannot be null or empty", nameof(createImageRequest)); } @@ -606,7 +586,7 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : throw new ArgumentException($"Prompt cannot be longer than 1000 characters", nameof(createImageRequest)); } - if (createImageRequest.NumberOfImagesToGenerate<0) + if (createImageRequest.NumberOfImagesToGenerate < 0) { throw new ArgumentException($"NumberOfImagesToGenerate must be between 1 and 10", nameof(createImageRequest)); } @@ -643,12 +623,12 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : throw new ArgumentException($"Image cannot be null", nameof(imageVariationRequest)); } - if(string.IsNullOrWhiteSpace(imageVariationRequest.Image.FileName)) + if (string.IsNullOrWhiteSpace(imageVariationRequest.Image.FileName)) { throw new ArgumentException($"Image.FileName cannot be null or empty", nameof(imageVariationRequest)); } - if(imageVariationRequest.Image.Content is null) + if (imageVariationRequest.Image.Content is null) { throw new ArgumentException($"Image.Content cannot be null", nameof(imageVariationRequest)); } @@ -657,7 +637,7 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : { throw new ArgumentException($"Image.Content.Length cannot be 0", nameof(imageVariationRequest)); } - + MultipartFormDataContent formContent = new() { { new ByteArrayContent(imageVariationRequest.Image.Content), "image", imageVariationRequest.Image.FileName }, @@ -666,7 +646,7 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : }; - if(imageVariationRequest.NumberOfImagesToGenerate!=1) + if (imageVariationRequest.NumberOfImagesToGenerate != 1) formContent.Add(new StringContent(imageVariationRequest.NumberOfImagesToGenerate.ToString(CultureInfo.InvariantCulture)), "n"); @@ -674,17 +654,15 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : formContent.Add(new StringContent(imageVariationRequest.User), "user"); - using (var httpReq = new HttpRequestMessage(HttpMethod.Post, "images/variations")) - { - httpReq.Content = formContent; - - using (HttpResponseMessage? httpResponse = cancellationToken is null ? - await _client.SendAsync(httpReq).ConfigureAwait(false) : - await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false)) - { - return await ProcessResponseAsync(httpResponse, cancellationToken); - } - } + HttpRequestMessage httpReq = CreateRequestMessage(HttpMethod.Post, "images/variations"); + + httpReq.Content = formContent; + + using HttpResponseMessage? httpResponse = cancellationToken is null ? + await _client.SendAsync(httpReq).ConfigureAwait(false) : + await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false); + + return await ProcessResponseAsync(httpResponse, cancellationToken); } @@ -705,7 +683,7 @@ await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false)) { throw new ArgumentException($"Prompt cannot be longer than 1000 characters", nameof(imageEditRequest)); } - + if (imageEditRequest.NumberOfImagesToGenerate < 0) { throw new ArgumentException($"NumberOfImagesToGenerate must be between 1 and 10", nameof(imageEditRequest)); @@ -738,7 +716,7 @@ await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false)) ByteArrayContent? maskContent = null; - if(imageEditRequest.Mask is not null) + if (imageEditRequest.Mask is not null) { if (imageEditRequest.Mask.Content is null) { @@ -784,16 +762,16 @@ await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false)) formContent.Add(new StringContent(imageEditRequest.User), "user"); - using var httpReq = new HttpRequestMessage(HttpMethod.Post, "images/edits"); + using HttpRequestMessage httpReq = CreateRequestMessage(HttpMethod.Post, "images/edits"); httpReq.Content = formContent; using HttpResponseMessage? httpResponse = await _client.SendAsync( - httpReq, + httpReq, cancellationToken is null ? CancellationToken.None : cancellationToken.Value).ConfigureAwait(false); - + return await ProcessResponseAsync(httpResponse, cancellationToken); } - + /// public async Task DownloadImageAsync(GeneratedImage generatedImage, CancellationToken? cancellationToken = null) { @@ -822,16 +800,16 @@ await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false)) throw new ArgumentException($"Url is not a valid Uri", nameof(generatedImage)); } - + // Creating the message bypasses the authentication header for this request by design. + // The image is public and does not require authentication. An error is generated if + // the Authorization header is present. using HttpRequestMessage requestMessage = new() { Method = HttpMethod.Get, RequestUri = uri }; - using HttpClient imageClient = new(); - - using HttpResponseMessage? httpResponse = await imageClient.SendAsync(requestMessage, cancelToken).ConfigureAwait(false); + using HttpResponseMessage? httpResponse = await _client.SendAsync(requestMessage, cancelToken).ConfigureAwait(false); if (httpResponse is not null) { @@ -860,34 +838,30 @@ await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false)) where T : class where TR : class { - using (var httpReq = new HttpRequestMessage(method, url)) - { - string requestString = JsonSerializer.Serialize(requestMessage); + using HttpRequestMessage httpReq = CreateRequestMessage(method, url); - httpReq.Content = new StringContent(requestString, Encoding.UTF8, "application/json"); + string requestString = JsonSerializer.Serialize(requestMessage); - using (HttpResponseMessage? httpResponse = cancellationToken is null ? - await _client.SendAsync(httpReq) : - await _client.SendAsync(httpReq, cancellationToken.Value)) - { - return await ProcessResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - } + httpReq.Content = new StringContent(requestString, Encoding.UTF8, "application/json"); + + using HttpResponseMessage? httpResponse = cancellationToken is null ? + await _client.SendAsync(httpReq).ConfigureAwait(false) : + await _client.SendAsync(httpReq, cancellationToken.Value).ConfigureAwait(false); + return await ProcessResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); } + + private async Task SendRequestAsync(HttpMethod method, string url, CancellationToken? cancellationToken) where T : class { - using (var request = new HttpRequestMessage(method, url)) - { - using (HttpResponseMessage? httpResponse = cancellationToken is null ? - await _client.SendAsync(request).ConfigureAwait(false) : - await _client.SendAsync(request, cancellationToken.Value).ConfigureAwait(false)) - { - return await ProcessResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); + using HttpRequestMessage request = CreateRequestMessage(method, url); - } - } - } + using HttpResponseMessage? httpResponse = cancellationToken is null ? + await _client.SendAsync(request).ConfigureAwait(false) : + await _client.SendAsync(request, cancellationToken.Value).ConfigureAwait(false); + + return await ProcessResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); + } private async static Task ProcessResponseAsync(HttpResponseMessage responseMessage, CancellationToken? cancellationToken) where T : class { @@ -931,9 +905,23 @@ await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false) : return responseString; } -#endregion + private HttpRequestMessage CreateRequestMessage(HttpMethod method, string url) + { + HttpRequestMessage request = new(method, url); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _chatCredentials.ApiKey); + + if (!string.IsNullOrWhiteSpace(_chatCredentials.Organization)) + { + request.Headers.Add("OpenAI-Organization", _chatCredentials.Organization); + } + + return request; + } + + #endregion -#region Clean Up + #region Clean Up ~ChatGPTClient() { Dispose(true); @@ -956,5 +944,5 @@ protected void Dispose(bool disposing) _isDisposed = true; } } -#endregion + #endregion } diff --git a/src/Whetstone.ChatGPT/RELEASENOTES.md b/src/Whetstone.ChatGPT/RELEASENOTES.md deleted file mode 100644 index 335c306d..00000000 --- a/src/Whetstone.ChatGPT/RELEASENOTES.md +++ /dev/null @@ -1,7 +0,0 @@ - -# 1.1.0 - -- Added IChatGPTClient interface method for: - - StreamFineTuneEventsAsync - - DownloadImageAsync -- Small updates from code analysis suggestions \ No newline at end of file diff --git a/src/Whetstone.ChatGPT/Whetstone.ChatGPT.csproj b/src/Whetstone.ChatGPT/Whetstone.ChatGPT.csproj index b00e6c10..4509ca55 100644 --- a/src/Whetstone.ChatGPT/Whetstone.ChatGPT.csproj +++ b/src/Whetstone.ChatGPT/Whetstone.ChatGPT.csproj @@ -7,21 +7,26 @@ Whetstone ChatGPT README.md - # 1.1.0 +# 1.2.0 - - Added IChatGPTClient interface method for: - - StreamFineTuneEventsAsync - - DownloadImageAsync - - Small updates from code analysis suggestions +- Added support for dependency injection +- Some ChatGPTClient constructors were removed + +# 1.1.0 + +- Added IChatGPTClient interface method for: +- StreamFineTuneEventsAsync +- DownloadImageAsync +- Small updates from code analysis suggestions git chatgpt; api; openapi; gpt; gpt-3 https://github.com/johniwasz/whetstone.chatgpt 2023 - 1.1.1 + 1.2.0 https://github.com/johniwasz/whetstone.chatgpt - A simple light-weight library that wraps the ChatGPT API. + A simple light-weight library that wraps the ChatGPT API. Includes support for dependency injection. packlogo.png True MIT @@ -64,5 +69,5 @@ - + diff --git a/src/examples/AzureARMTemplates/deploy.ps1 b/src/examples/AzureARMTemplates/deploy.ps1 deleted file mode 100644 index 4eaccef7..00000000 --- a/src/examples/AzureARMTemplates/deploy.ps1 +++ /dev/null @@ -1,28 +0,0 @@ - -$resourceGroupName = "twitter-chatgpt" -New-AzResourceGroup ` - -Name $resourceGroupName ` - -Location "East US" - - # $templateFile = ".\akvdeployment.json" - $templateFile = ".\akvdeployment.bicep" - - $twitterAccessToken = ConvertTo-SecureString $Env:TWITTER_ACCESS_TOKEN -AsPlainText -Force - $twitterAccessTokenSecret = ConvertTo-SecureString $Env:TWITTER_ACCESS_TOKEN_SECRET -AsPlainText -Force - $twitterConsumerKey = ConvertTo-SecureString $Env:TWITTER_CONSUMER_KEY -AsPlainText -Force - $twitterConsumerSecret = ConvertTo-SecureString $Env:TWITTER_CONSUMER_SECRET -AsPlainText -Force - $tenantId = ConvertTo-SecureString $Env:AZURE_TENANT_ID -AsPlainText -Force - $objectId = ConvertTo-SecureString $Env:AZURE_OBJECT_ID -AsPlainText -Force - - New-AzResourceGroupDeployment ` - -Name twitter-chatgpt-resources ` - -ResourceGroupName $resourceGroupName ` - -TemplateFile $templateFile ` - -tenantId $tenantId ` - -objectId $objectId ` - -twitterAccessToken $twitterAccessToken ` - -twitterAccessTokenSecret $twitterAccessTokenSecret ` - -twitterConsumerKey $twitterConsumerKey ` - -twitterConsumerSecret $twitterConsumerSecret - - \ No newline at end of file diff --git a/src/examples/AzureDeployments/deploy.ps1 b/src/examples/AzureDeployments/deploy.ps1 new file mode 100644 index 00000000..8321ada6 --- /dev/null +++ b/src/examples/AzureDeployments/deploy.ps1 @@ -0,0 +1,18 @@ + +$resourceGroupName = "twitter-chatgpt" + +New-AzResourceGroup ` + -Name $resourceGroupName ` + -Location "East US" + +$templateFile = ".\twitterfunc.bicep" + +$twitterCredsJson = ConvertTo-SecureString $Env:TWITTER_CREDS -AsPlainText -Force +$OpenAIAPICreds = ConvertTo-SecureString $Env:OPENAI_API_CREDS -AsPlainText -Force + +New-AzResourceGroupDeployment ` + -Name twitter-chatgpt-resources ` + -ResourceGroupName $resourceGroupName ` + -TemplateFile $templateFile ` + -twitterCredsJson $twitterCredsJson ` + -openAIAPICredsJson $OpenAIAPICreds \ No newline at end of file diff --git a/src/examples/AzureARMTemplates/akvdeployment.bicep b/src/examples/AzureDeployments/twitterfunc.bicep similarity index 56% rename from src/examples/AzureARMTemplates/akvdeployment.bicep rename to src/examples/AzureDeployments/twitterfunc.bicep index 44f8d133..1c016e58 100644 --- a/src/examples/AzureARMTemplates/akvdeployment.bicep +++ b/src/examples/AzureDeployments/twitterfunc.bicep @@ -1,50 +1,33 @@ -@secure() -param tenantId string - @description('The name of the function app that you wish to create.') param appName string = 'fn-twitgpt-${uniqueString(resourceGroup().id)}' @description('Location for all resources.') param location string = resourceGroup().location -@description('Specifies the object ID of a user, service principal or security group in the Azure Active Directory tenant for the vault. The object ID must be unique for the list of access policies. Get it by using Get-AzADUser or Get-AzADServicePrincipal cmdlets.') -@secure() -param objectId string - -@description('Twitter Access Token for invoking user-specific operations.') -@secure() -param twitterAccessToken string - -@description('Twitter Access Token Secret for invoking user-specific operations.') +@description('Twitter credentials in JSON.') @secure() -param twitterAccessTokenSecret string +param twitterCredsJson string -@description('Twitter Consumer Key for invoking public operations.') +@description('OpenAI credentials in JSON.') @secure() -param twitterConsumerKey string - -@description('Twitter Consumer Secret for invoking public operations.') -@secure() -param twitterConsumerSecret string +param openAIAPICredsJson string @description('region of the resouce group.') param twitgptgrouppname string = 'eastus' param storageAccountType string = 'Standard_LRS' + +var keyVaultName = 'kvgpt-dev-${uniqueString(resourceGroup().id)}' + +var tenantId = tenant().tenantId var hostingPlanName = appName var applicationInsightsName = appName var storageAccountName = '${uniqueString(resourceGroup().id)}azfunctions' - -resource twitter_chatgpt_funcid 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { - name: 'twitter-chatgpt-funcid' - location: twitgptgrouppname -} - -resource twitterchatgpt_dev_kv01 'Microsoft.KeyVault/vaults@2019-09-01' = { - name: 'twitterchatgpt-dev' +resource twitterchatgpt_dev_kv 'Microsoft.KeyVault/vaults@2019-09-01' = { + name: keyVaultName location: twitgptgrouppname tags: { displayName: 'twitterchatgpt' @@ -54,30 +37,8 @@ resource twitterchatgpt_dev_kv01 'Microsoft.KeyVault/vaults@2019-09-01' = { enabledForTemplateDeployment: true enabledForDiskEncryption: true tenantId: tenantId - accessPolicies: [ - { - tenantId: tenantId - objectId: objectId - permissions: { - keys: [ - 'all' - ] - secrets: [ - 'all' - ] - } - } - { - tenantId: tenantId - objectId: twitter_chatgpt_funcid.properties.principalId - permissions: { - secrets: [ - 'Get' - ] - } - } - ] enableSoftDelete: true + accessPolicies: [] sku: { name: 'standard' family: 'A' @@ -85,38 +46,51 @@ resource twitterchatgpt_dev_kv01 'Microsoft.KeyVault/vaults@2019-09-01' = { } } -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2018-09-01-preview' = { - scope: twitterchatgpt_dev_kv01 - name: guid(twitterchatgpt_dev_kv01.id, twitter_chatgpt_funcid.id, 'Key Vault Secrets User') +resource twitterchatgpt_dev_kv_twittercreds 'Microsoft.KeyVault/vaults/secrets@2016-10-01' = { + parent: twitterchatgpt_dev_kv + name: 'twittercreds' + tags: { + displayName: 'twitterchatgpt' + } properties: { - roleDefinitionId: '4633458b-17de-408a-b874-0445c86b69e6' - principalId: twitter_chatgpt_funcid.properties.principalId + value: twitterCredsJson } } -resource twitterchatgpt_dev_kv01_twittercreds 'Microsoft.KeyVault/vaults/secrets@2016-10-01' = { - parent: twitterchatgpt_dev_kv01 - name: 'twittercreds' +resource twitterchatgpt_dev_kv_openaiapicreds 'Microsoft.KeyVault/vaults/secrets@2016-10-01' = { + parent: twitterchatgpt_dev_kv + name: 'openaicreds' + tags: { + displayName: 'twitterchatgpt' + } properties: { - value: '{ "AccessToken": "${twitterAccessToken}", "AccessTokenSecret": "${twitterAccessTokenSecret}", "ConsumerKey": "${twitterConsumerKey}", "ConsumerSecret": "${twitterConsumerSecret}"}' + value: openAIAPICredsJson } } resource storageAccount 'Microsoft.Storage/storageAccounts@2021-08-01' = { name: storageAccountName location: location + tags: { + displayName: 'twitterchatgpt' + } sku: { name: storageAccountType } kind: 'Storage' properties: { - + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + allowBlobPublicAccess: false } } resource hostingPlan 'Microsoft.Web/serverfarms@2021-03-01' = { name: hostingPlanName location: location + tags: { + displayName: 'twitterchatgpt' + } sku: { name: 'Y1' tier: 'Dynamic' @@ -127,19 +101,18 @@ resource function 'Microsoft.Web/sites@2020-12-01' = { name: appName location: twitgptgrouppname kind: 'functionapp' + tags: { + displayName: 'twitterchatgpt' + } identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${twitter_chatgpt_funcid.id}' : {} - } + type: 'SystemAssigned' } properties: { serverFarmId: hostingPlan.id siteConfig: { ftpsState: 'Disabled' minTlsVersion: '1.2' - http20Enabled: true - keyVaultReferenceIdentity: twitter_chatgpt_funcid.id + http20Enabled: true appSettings: [ { name: 'AzureWebJobsDashboard' @@ -171,7 +144,11 @@ resource function 'Microsoft.Web/sites@2020-12-01' = { } { name: 'TWITTER_CREDS' - value: '@MicrosoftValueSecret(${twitterchatgpt_dev_kv01_twittercreds.id})' + value: '@Microsoft.KeyVault(VaultName=${twitterchatgpt_dev_kv.name};SecretName=${twitterchatgpt_dev_kv_twittercreds.name})' + } + { + name: 'OPENAI_API_CREDS' + value: '@Microsoft.KeyVault(VaultName=${twitterchatgpt_dev_kv.name};SecretName=${twitterchatgpt_dev_kv_openaiapicreds.name})' } ] } @@ -183,8 +160,22 @@ resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { name: applicationInsightsName location: location kind: 'web' + tags: { + displayName: 'twitterchatgpt' + } properties: { Application_Type: 'web' Request_Source: 'rest' } } + +module appService 'updateakv.bicep' = { + name: 'appService' + params: { + funcName: function.name + location: location + keyVaultName: keyVaultName + } +} + +output createdFunction string = function.name diff --git a/src/examples/AzureDeployments/updateakv.bicep b/src/examples/AzureDeployments/updateakv.bicep new file mode 100644 index 00000000..c2365b7c --- /dev/null +++ b/src/examples/AzureDeployments/updateakv.bicep @@ -0,0 +1,44 @@ + +@description('Location for all resources.') +param funcName string + +@description('Location for all resources.') +param location string = resourceGroup().location + +@minLength(3) +@maxLength(24) +@description('Name of the keyvault that stores Twitter and OpenAI credentials.') +param keyVaultName string + +var tenantId = tenant().tenantId + + +resource function 'Microsoft.Web/sites@2020-12-01' existing = { + name: funcName +} + +resource twitterchatgpt_dev_kv 'Microsoft.KeyVault/vaults@2019-09-01' = { + name: keyVaultName + location: location + tags: { + displayName: 'twitterchatgpt' + } + properties: { + tenantId: tenantId + accessPolicies: [ + { + tenantId: tenantId + objectId: function.identity.principalId + permissions: { + secrets: [ + 'get' + ] + } + } + ] + sku: { + name: 'standard' + family: 'A' + } + } +} diff --git a/src/examples/Whetstone.TweetGPT.DirectMessageFunction/Program.cs b/src/examples/Whetstone.TweetGPT.DirectMessageFunction/Program.cs index 014665c2..e536ab84 100644 --- a/src/examples/Whetstone.TweetGPT.DirectMessageFunction/Program.cs +++ b/src/examples/Whetstone.TweetGPT.DirectMessageFunction/Program.cs @@ -41,21 +41,21 @@ public static void Main(string[] args) services.Configure(options => { IConfiguration config = context.Configuration; - + string? gptCredentialsText = config["OPENAI_API_CREDS"]; if (string.IsNullOrWhiteSpace(gptCredentialsText)) { throw new InvalidOperationException("OPENAI_API_CREDS is not set."); } - + ChatGPTCredentials? gptCredentials = JsonSerializer.Deserialize(gptCredentialsText); if (gptCredentials == null) { throw new InvalidOperationException("OPENAI_API_CREDS is not set."); } - + options.ApiKey = gptCredentials.ApiKey; options.Organization = gptCredentials.Organization; @@ -63,40 +63,40 @@ public static void Main(string[] args) services.Configure(options => - { - IConfiguration config = context.Configuration; - string? twitterCredString = config["TWITTER_CREDS"]; + { + IConfiguration config = context.Configuration; + string? twitterCredString = config["TWITTER_CREDS"]; - WebhookCredentials? twitterCreds = string.IsNullOrWhiteSpace(twitterCredString) ? - null : JsonSerializer.Deserialize(twitterCredString); + WebhookCredentials? twitterCreds = string.IsNullOrWhiteSpace(twitterCredString) ? + null : JsonSerializer.Deserialize(twitterCredString); - if (twitterCreds is not null) - { - options.AccessToken = twitterCreds.AccessToken; - options.AccessTokenSecret = twitterCreds.AccessTokenSecret; - options.ConsumerSecret = twitterCreds.ConsumerSecret; - options.ConsumerKey = twitterCreds.ConsumerKey; - } + if (twitterCreds is not null) + { + options.AccessToken = twitterCreds.AccessToken; + options.AccessTokenSecret = twitterCreds.AccessTokenSecret; + options.ConsumerSecret = twitterCreds.ConsumerSecret; + options.ConsumerKey = twitterCreds.ConsumerKey; + } #if DEBUG - if (twitterCreds is null) - { - options.AccessToken = config["WebhookCredentials:AccessToken"]; - options.AccessTokenSecret = config["WebhookCredentials:AccessTokenSecret"]; - options.ConsumerSecret = config["WebhookCredentials:ConsumerSecret"]; - options.ConsumerKey = config["WebhookCredentials:ConsumerKey"]; - } + if (twitterCreds is null) + { + options.AccessToken = config["WebhookCredentials:AccessToken"]; + options.AccessTokenSecret = config["WebhookCredentials:AccessTokenSecret"]; + options.ConsumerSecret = config["WebhookCredentials:ConsumerSecret"]; + options.ConsumerKey = config["WebhookCredentials:ConsumerKey"]; + } #endif - }); + }); + /// services.AddHttpClient(); - services.AddHttpClient() .SetHandlerLifetime(TimeSpan.FromSeconds(150)) .AddPolicyHandler(GetRetryPolicy()); services.AddLogging(); - // services.AddScoped(); + services.AddScoped(); }); var host = hostBuilder.Build(); diff --git a/src/examples/Whetstone.TweetGPT.DirectMessageFunction/WhetstoneTrigger.cs b/src/examples/Whetstone.TweetGPT.DirectMessageFunction/WhetstoneTrigger.cs index 8a235717..43d289eb 100644 --- a/src/examples/Whetstone.TweetGPT.DirectMessageFunction/WhetstoneTrigger.cs +++ b/src/examples/Whetstone.TweetGPT.DirectMessageFunction/WhetstoneTrigger.cs @@ -41,7 +41,7 @@ public class ChatCPTDirectMessageFunction private readonly string _basePrompt; public ChatCPTDirectMessageFunction(IChatGPTClient chatGPT, IOptions creds, ILogger logger) - { + { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _creds = creds?.Value ?? throw new ArgumentNullException(nameof(creds)); @@ -71,7 +71,7 @@ public async Task RunAsync( FunctionContext executionContext) { WebhooksRequestHandlerForAzureFunction request = new WebhooksRequestHandlerForAzureFunction(req); - + IAccountActivityRequestHandler activityHandler = _client.AccountActivity.CreateRequestHandler(); long? userId = await GetUserIdAsync(request).ConfigureAwait(false); @@ -79,7 +79,7 @@ public async Task RunAsync( if (userId.HasValue) { _logger.LogInformation($"Processing message for user {userId.Value}"); - + IAccountActivityStream activityStream = activityHandler.GetAccountActivityStream(userId.Value, "devchatgpt"); if (!_trackedStreams.Contains(activityStream.AccountUserId)) @@ -147,7 +147,7 @@ private void MessageReceived(object? sender, MessageReceivedEvent? e) } } } - catch(ChatGPTException chatEx) + catch (ChatGPTException chatEx) { _logger.LogError(chatEx, $"ChatGPT had an error processing prompt: {messageFromSender}"); diff --git a/src/examples/Whetstone.WebhookCommander/Commands/UnsubscribeCommand.cs b/src/examples/Whetstone.WebhookCommander/Commands/UnsubscribeCommand.cs index f04ca0bf..107cf309 100644 --- a/src/examples/Whetstone.WebhookCommander/Commands/UnsubscribeCommand.cs +++ b/src/examples/Whetstone.WebhookCommander/Commands/UnsubscribeCommand.cs @@ -31,7 +31,7 @@ public async Task ExecuteAsync() isValid = false; } - } + } else { Logger.LogError("User id is required"); diff --git a/src/examples/Whetstone.WebhookCommander/registerwebhook.bat b/src/examples/Whetstone.WebhookCommander/registerwebhook.bat index 2d972c38..93cfbbaa 100644 --- a/src/examples/Whetstone.WebhookCommander/registerwebhook.bat +++ b/src/examples/Whetstone.WebhookCommander/registerwebhook.bat @@ -1,5 +1,6 @@  REM ngrok http http://localhost:7070 -REM bin\Debug\net6.0\twithookconfig removewebhook --e devchatgpt --wi 1611802887591546883 -bin\Debug\net6.0\twithookconfig addwebhook --e devchatgpt --webhookurl https://fn-twitgpt-uqvq5cmh43iws.azurewebsites.net/api/chatgptdm +REM bin\Debug\net6.0\twithookconfig list webhooks --e devchatgpt +REM bin\Debug\net6.0\twithookconfig removewebhook --e devchatgpt --wi 1000000000000 +bin\Debug\net6.0\twithookconfig addwebhook --e devchatgpt --webhookurl https://fn-myfunc.azurewebsites.net/api/chatgptdm bin\Debug\net6.0\twithookconfig subscribe --e devchatgpt \ No newline at end of file