Skip to content

Commit

Permalink
OpenAI-DotNet 8.2.4 (#365)
Browse files Browse the repository at this point in the history
- Fixed ResponseObjectFormat deserialization when set to auto
- Added RankingOptions to FileSearchOptions
- Fixed potential memory leaks when uploading files to various endpoints
- Added timestamp values to BaseResponse to calculate rate limits

---------

Co-authored-by: Stillkill <[email protected]>
  • Loading branch information
StephenHodgson and RealStillkill authored Sep 14, 2024
1 parent d79c4d4 commit 455c361
Show file tree
Hide file tree
Showing 15 changed files with 284 additions and 85 deletions.
2 changes: 1 addition & 1 deletion OpenAI-DotNet-Tests/TestFixture_02_Assistants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public async Task Test_01_Assistants()
["int"] = "1",
["test"] = Guid.NewGuid().ToString()
},
tools: new[] { Tool.FileSearch });
tools: new[] { new Tool(new FileSearchOptions(15, new RankingOptions("auto", 0.5f))) });
var assistant = await OpenAIClient.AssistantsEndpoint.CreateAssistantAsync(request);
Assert.IsNotNull(assistant);

Expand Down
2 changes: 2 additions & 0 deletions OpenAI-DotNet/Assistants/AssistantResponse.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Licensed under the MIT License. See LICENSE in the project root for license information.

using OpenAI.Extensions;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
Expand Down Expand Up @@ -136,6 +137,7 @@ public sealed class AssistantResponse : BaseResponse
/// </remarks>
[JsonInclude]
[JsonPropertyName("response_format")]
[JsonConverter(typeof(ResponseFormatConverter))]
public ResponseFormatObject ResponseFormatObject { get; private set; }

[JsonIgnore]
Expand Down
2 changes: 2 additions & 0 deletions OpenAI-DotNet/Assistants/CreateAssistantRequest.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Licensed under the MIT License. See LICENSE in the project root for license information.

using OpenAI.Extensions;
using System;
using System.Collections.Generic;
using System.Linq;
Expand Down Expand Up @@ -275,6 +276,7 @@ public CreateAssistantRequest(
/// which indicates the generation exceeded max_tokens or the conversation exceeded the max context length.
/// </remarks>
[JsonPropertyName("response_format")]
[JsonConverter(typeof(ResponseFormatConverter))]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ResponseFormatObject ResponseFormatObject { get; internal set; }

Expand Down
94 changes: 53 additions & 41 deletions OpenAI-DotNet/Audio/AudioEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,41 +100,47 @@ public async Task<AudioResponse> CreateTranscriptionJsonAsync(AudioTranscription

private async Task<(HttpResponseMessage, string)> Internal_CreateTranscriptionAsync(AudioTranscriptionRequest request, CancellationToken cancellationToken = default)
{
using var content = new MultipartFormDataContent();
using var audioData = new MemoryStream();
await request.Audio.CopyToAsync(audioData, cancellationToken).ConfigureAwait(false);
content.Add(new ByteArrayContent(audioData.ToArray()), "file", request.AudioName);
content.Add(new StringContent(request.Model), "model");
using var payload = new MultipartFormDataContent();

if (!string.IsNullOrWhiteSpace(request.Language))
try
{
content.Add(new StringContent(request.Language), "language");
}
using var audioData = new MemoryStream();
await request.Audio.CopyToAsync(audioData, cancellationToken).ConfigureAwait(false);
payload.Add(new ByteArrayContent(audioData.ToArray()), "file", request.AudioName);
payload.Add(new StringContent(request.Model), "model");

if (!string.IsNullOrWhiteSpace(request.Prompt))
{
content.Add(new StringContent(request.Prompt), "prompt");
}
if (!string.IsNullOrWhiteSpace(request.Language))
{
payload.Add(new StringContent(request.Language), "language");
}

if (!string.IsNullOrWhiteSpace(request.Prompt))
{
payload.Add(new StringContent(request.Prompt), "prompt");
}

content.Add(new StringContent(request.ResponseFormat.ToString().ToLower()), "response_format");
payload.Add(new StringContent(request.ResponseFormat.ToString().ToLower()), "response_format");

if (request.Temperature.HasValue)
{
content.Add(new StringContent(request.Temperature.Value.ToString(CultureInfo.InvariantCulture)), "temperature");
}
if (request.Temperature.HasValue)
{
payload.Add(new StringContent(request.Temperature.Value.ToString(CultureInfo.InvariantCulture)), "temperature");
}

switch (request.TimestampGranularities)
switch (request.TimestampGranularities)
{
case TimestampGranularity.Segment:
case TimestampGranularity.Word:
payload.Add(new StringContent(request.TimestampGranularities.ToString().ToLower()), "timestamp_granularities[]");
break;
}
}
finally
{
case TimestampGranularity.Segment:
case TimestampGranularity.Word:
content.Add(new StringContent(request.TimestampGranularities.ToString().ToLower()), "timestamp_granularities[]");
break;
request.Dispose();
}

request.Dispose();

using var response = await client.Client.PostAsync(GetUrl("/transcriptions"), content, cancellationToken).ConfigureAwait(false);
var responseAsString = await response.ReadAsStringAsync(EnableDebug, content, cancellationToken).ConfigureAwait(false);
using var response = await client.Client.PostAsync(GetUrl("/transcriptions"), payload, cancellationToken).ConfigureAwait(false);
var responseAsString = await response.ReadAsStringAsync(EnableDebug, payload, cancellationToken).ConfigureAwait(false);
return (response, responseAsString);
}

Expand Down Expand Up @@ -172,28 +178,34 @@ public async Task<AudioResponse> CreateTranslationJsonAsync(AudioTranslationRequ

private async Task<(HttpResponseMessage, string)> Internal_CreateTranslationAsync(AudioTranslationRequest request, CancellationToken cancellationToken = default)
{
using var content = new MultipartFormDataContent();
using var audioData = new MemoryStream();
await request.Audio.CopyToAsync(audioData, cancellationToken).ConfigureAwait(false);
content.Add(new ByteArrayContent(audioData.ToArray()), "file", request.AudioName);
content.Add(new StringContent(request.Model), "model");
using var payload = new MultipartFormDataContent();

if (!string.IsNullOrWhiteSpace(request.Prompt))
try
{
content.Add(new StringContent(request.Prompt), "prompt");
}
using var audioData = new MemoryStream();
await request.Audio.CopyToAsync(audioData, cancellationToken).ConfigureAwait(false);
payload.Add(new ByteArrayContent(audioData.ToArray()), "file", request.AudioName);
payload.Add(new StringContent(request.Model), "model");

if (!string.IsNullOrWhiteSpace(request.Prompt))
{
payload.Add(new StringContent(request.Prompt), "prompt");
}

content.Add(new StringContent(request.ResponseFormat.ToString().ToLower()), "response_format");
payload.Add(new StringContent(request.ResponseFormat.ToString().ToLower()), "response_format");

if (request.Temperature.HasValue)
if (request.Temperature.HasValue)
{
payload.Add(new StringContent(request.Temperature.Value.ToString(CultureInfo.InvariantCulture)), "temperature");
}
}
finally
{
content.Add(new StringContent(request.Temperature.Value.ToString(CultureInfo.InvariantCulture)), "temperature");
request.Dispose();
}

request.Dispose();

using var response = await client.Client.PostAsync(GetUrl("/translations"), content, cancellationToken).ConfigureAwait(false);
var responseAsString = await response.ReadAsStringAsync(EnableDebug, content, cancellationToken).ConfigureAwait(false);
using var response = await client.Client.PostAsync(GetUrl("/translations"), payload, cancellationToken).ConfigureAwait(false);
var responseAsString = await response.ReadAsStringAsync(EnableDebug, payload, cancellationToken).ConfigureAwait(false);
return (response, responseAsString);
}
}
Expand Down
2 changes: 2 additions & 0 deletions OpenAI-DotNet/Chat/ChatRequest.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Licensed under the MIT License. See LICENSE in the project root for license information.

using OpenAI.Extensions;
using System;
using System.Collections.Generic;
using System.Linq;
Expand Down Expand Up @@ -281,6 +282,7 @@ public ChatRequest(
public ChatResponseFormat ResponseFormat => ResponseFormatObject ?? ChatResponseFormat.Auto;

[JsonPropertyName("response_format")]
[JsonConverter(typeof(ResponseFormatConverter))]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ResponseFormatObject ResponseFormatObject { get; internal set; }

Expand Down
61 changes: 60 additions & 1 deletion OpenAI-DotNet/Common/BaseResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using OpenAI.Extensions;
using System;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

namespace OpenAI
{
Expand All @@ -27,7 +28,8 @@ public abstract class BaseResponse
public string Organization { get; internal set; }

/// <summary>
/// The request id of this API call, as reported in the response headers. This may be useful for troubleshooting or when contacting OpenAI support in reference to a specific request.
/// The request id of this API call, as reported in the response headers.
/// This may be useful for troubleshooting or when contacting OpenAI support in reference to a specific request.
/// </summary>
[JsonIgnore]
public string RequestId { get; internal set; }
Expand Down Expand Up @@ -68,12 +70,69 @@ public abstract class BaseResponse
[JsonIgnore]
public string ResetRequests { get; internal set; }

/// <summary>
/// The time until the rate limit (based on requests) resets to its initial state represented as a TimeSpan.
/// </summary>
[JsonIgnore]
public TimeSpan ResetRequestsTimespan => ConvertTimestampToTimespan(ResetTokens);

/// <summary>
/// The time until the rate limit (based on tokens) resets to its initial state.
/// </summary>
[JsonIgnore]
public string ResetTokens { get; internal set; }

/// <summary>
/// The time until the rate limit (based on tokens) resets to its initial state represented as a TimeSpan.
/// </summary>
[JsonIgnore]
public TimeSpan ResetTokensTimespan => ConvertTimestampToTimespan(ResetTokens);

/*
* Regex Notes:
* The gist of this regex is that it is searching for "timestamp segments", e.g. 1m or 144ms.
* Each segment gets matched into its respective named capture group, from which we further parse out the
* digits. This allows us to take the string 6m45s99ms and insert the integers into a
* TimeSpan object for easier use.
*
* Regex Performance Notes, against 100k randomly generated timestamps:
* Average performance: 0.0003ms
* Best case: 0ms
* Worst Case: 15ms
* Total Time: 30ms
*
* Inconsequential compute time
*/
private readonly Regex timestampRegex = new Regex(@"^(?<h>\d+h)?(?<m>\d+m(?!s))?(?<s>\d+s)?(?<ms>\d+ms)?");

/// <summary>
/// Takes a timestamp received from a OpenAI response header and converts to a TimeSpan
/// </summary>
/// <param name="timestamp">The timestamp received from an OpenAI header, e.g. x-ratelimit-reset-tokens</param>
/// <returns>A TimeSpan that represents the timestamp provided</returns>
/// <exception cref="ArgumentException">Thrown if the provided timestamp is not in the expected format, or if the match is not successful.</exception>
private TimeSpan ConvertTimestampToTimespan(string timestamp)
{
var match = timestampRegex.Match(timestamp);

if (!match.Success)
{
throw new ArgumentException($"Could not parse timestamp header. '{timestamp}'.");
}

/*
* Note about Hours in timestamps:
* I have not personally observed a timestamp with an hours segment (e.g. 1h30m15s1ms).
* Although their presence may not actually exist, we can still have this section in the parser, there is no
* negative impact for a missing hours segment because the capture groups are flagged as optional.
*/
int.TryParse(match.Groups["h"].Value.Replace("h", string.Empty), out var h);
int.TryParse(match.Groups["m"].Value.Replace("m", string.Empty), out var m);
int.TryParse(match.Groups["s"].Value.Replace("s", string.Empty), out var s);
int.TryParse(match.Groups["ms"].Value.Replace("ms", string.Empty), out var ms);
return new TimeSpan(h, m, s) + TimeSpan.FromMilliseconds(ms);
}

public string ToJsonString()
=> this.ToEscapedJsonString<object>();
}
Expand Down
7 changes: 6 additions & 1 deletion OpenAI-DotNet/Common/FileSearchOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@ public sealed class FileSearchOptions
{
public FileSearchOptions() { }

public FileSearchOptions(int maxNumberOfResults)
public FileSearchOptions(int maxNumberOfResults, RankingOptions rankingOptions = null)
{
MaxNumberOfResults = maxNumberOfResults switch
{
< 1 => throw new ArgumentOutOfRangeException(nameof(maxNumberOfResults), "Max number of results must be greater than 0."),
> 50 => throw new ArgumentOutOfRangeException(nameof(maxNumberOfResults), "Max number of results must be less than 50."),
_ => maxNumberOfResults
};
RankingOptions = rankingOptions ?? new RankingOptions();
}

[JsonInclude]
[JsonPropertyName("max_num_results")]
public int MaxNumberOfResults { get; private set; }

[JsonInclude]
[JsonPropertyName("ranking_options")]
public RankingOptions RankingOptions { get; private set; }
}
}
50 changes: 50 additions & 0 deletions OpenAI-DotNet/Common/RankingOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System;
using System.Text.Json.Serialization;

namespace OpenAI
{
/// <summary>
/// The ranking options for the file search.
/// <see href="https://platform.openai.com/docs/assistants/tools/file-search/customizing-file-search-settings"/>
/// </summary>
public sealed class RankingOptions
{
/// <summary>
/// Constructor.
/// </summary>
/// <param name="ranker">
/// The ranker to use for the file search.
/// If not specified will use the `auto` ranker.
/// </param>
/// <param name="scoreThreshold">
/// The score threshold for the file search.
/// All values must be a floating point number between 0 and 1.
/// </param>
/// <exception cref="ArgumentOutOfRangeException"></exception>
[JsonConstructor]
public RankingOptions(string ranker = "auto", float scoreThreshold = 0f)
{
Ranker = ranker;
ScoreThreshold = scoreThreshold switch
{
< 0 => throw new ArgumentOutOfRangeException(nameof(scoreThreshold), "Score threshold must be greater than or equal to 0."),
> 1 => throw new ArgumentOutOfRangeException(nameof(scoreThreshold), "Score threshold must be less than or equal to 1."),
_ => scoreThreshold
};
}

/// <summary>
/// The ranker to use for the file search.
/// </summary>
[JsonPropertyName("ranker")]
public string Ranker { get; }

/// <summary>
/// The score threshold for the file search.
/// </summary>
[JsonPropertyName("score_threshold")]
public float ScoreThreshold { get; }
}
}
34 changes: 34 additions & 0 deletions OpenAI-DotNet/Extensions/ResponseFormatConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace OpenAI.Extensions
{
internal sealed class ResponseFormatConverter : JsonConverter<ResponseFormatObject>
{
public override ResponseFormatObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
try
{
if (reader.TokenType is JsonTokenType.Null or JsonTokenType.String)
{
return ChatResponseFormat.Auto;
}

return JsonSerializer.Deserialize<ResponseFormatObject>(ref reader, options);
}
catch (Exception e)
{
throw new Exception($"Error reading {typeof(ChatResponseFormat)} from JSON.", e);
}
}

public override void Write(Utf8JsonWriter writer, ResponseFormatObject value, JsonSerializerOptions options)
{
// serialize the object normally
JsonSerializer.Serialize(writer, value, options);
}
}
}
Loading

0 comments on commit 455c361

Please sign in to comment.