🐐

C#のOpenAI libraryを作りました。※2024-4-20 File search, vector store APIに対応

2023/11/11に公開

#概要
OpenAIのC#のライブラリを作ったので共有します。

#使い方
Download via Nuget
HigLabo.OpenAI

ソースはこちら
https://github.com/higty/higlabo/tree/master/Net8

サンプルコードはこちら
https://github.com/higty/higlabo/blob/master/Net8/HigLabo.OpenAI.SampleConsoleApp/OpenAIPlayground.cs

メインで使用するのはOpenAIClientクラスです。OpenAIのAPIを利用数場合はこんな感じ。

var apiKey = "your api key of OpenAI";
var client = new OpenAIClient(apiKey);

Azureのエンドポイントはこんな感じです。

var apiKey = "your api key of OpenAI";
var client = new OpenAIClient(new AzureSettings(apiKey, "https://tinybetter-work-for-our-future.openai.azure.com/", "MyDeploymentName"));

さっそくChatCompletionのエンドポイントを呼び出してみましょう。

var cl = new OpenAIClient("API KEY");
var p = new ChatCompletionsParameter();
p.Messages.Add(new ChatMessage(ChatMessageRole.User, $"How to enjoy coffee?"));
p.Model = "gpt-4";
var res = await cl.ChatCompletionsAsync(p);
foreach (var choice in res.Choices)
{
    Console.Write(choice.Message.Content);
}
Console.WriteLine();
Console.WriteLine("Total tokens: " + res.Usage.Total_Tokens);

ChatCompletionのレスポンスをリアルタイムで受け取るにはChatCompletionsStreamAsyncメソッドを使用します。

var cl = new OpenAIClient("API KEY");
var p = new ChatCompletionsParameter();
p.AddUserMessage($"How to enjoy coffee?");
p.Model = "gpt-4";

var result = new ChatCompletionStreamResult();
await foreach (string text in cl.ChatCompletionsStreamAsync(p, result, CancellationToken.None))
{
    Console.Write(text);
}
Console.WriteLine();
Console.WriteLine("Finish reason: " + result.GetFinishReason());

Function callingを使用する方法です。

var cl = new OpenAIClient("API KEY");
var p = new ChatCompletionsParameter();
//ChatGPT can correct Newyork,Sanflansisco to New york and San Flancisco.
p.Messages.Add(new ChatMessage(ChatMessageRole.User, $"I want to know the whether of these locations. Newyork, Sanflansisco, Paris, Tokyo."));
p.Model = "gpt-4";

{
    var tool = new ToolObject("function");
    tool.Function = new FunctionObject();
    tool.Function.Name = "getWhether";
    tool.Function.Description = "This service can get whether of specified location.";
    tool.Function.Parameters = new
    {
        type = "object",
        properties = new
        {
            locationList = new
            {
                type = "array",
                description = "Location list that you want to know.",
                items = new
                {
                    type = "string",
                }
            }
        }
    };
    p.Tools = new List<ToolObject>();
    p.Tools.Add(tool);
}
{
    var tool = new ToolObject("function");
    tool.Function = new FunctionObject();
    tool.Function.Name = "getLatLong";
    tool.Function.Description = "This service can get latitude and longitude of specified location.";
    tool.Function.Parameters = new
    {
        type = "object",
        properties = new
        {
            locationList = new
            {
                type = "array",
                description = "Location list that you want to know.",
                items = new
                {
                    type = "string",
                }
            }
        }
    };
    p.Tools = new List<ToolObject>();
    p.Tools.Add(tool);
}

var result = new ChatCompletionStreamResult();
await foreach (var text in cl.ChatCompletionsStreamAsync(p, result, CancellationToken.None))
{
    Console.Write(text);
}
Console.WriteLine();

foreach (var f in result.GetFunctionCallList())
{
    Console.WriteLine("Function name is " + f.Name);
    Console.WriteLine("Arguments is " + f.Arguments);
}

Vision APIで画像の内容を解説してもらう方法です。

var cl = new OpenAIClient("API KEY");
var p = new ChatCompletionsParameter();

var message = new ChatImageMessage(ChatMessageRole.User);
message.AddTextContent("Please describe this image.");
message.AddImageFile(Path.Combine(Environment.CurrentDirectory, "Image", "Pond.jpg"));
p.Messages.Add(message);
p.Model = "gpt-4-vision-preview";
p.Max_Tokens = 300;

var result = new ChatCompletionStreamResult();
await foreach (var text in cl.ChatCompletionsStreamAsync(p, result, CancellationToken.None))
{
    Console.Write(text);
}

アシスタントに渡す用のファイルをアップロードする方法です。Fine tuning用のファイルもアップロードできます。

var p = new FileUploadParameter();
p.SetFile("my_file.pdf", File.ReadAllBytes("D:\\Data\\my_file.pdf"));
p.Purpose = "assistants";
var res = await client.FileUploadAsync(p);
Console.WriteLine(res);

画像生成です。

var res = await client.ImagesGenerationsAsync("Blue sky and green field.");
foreach (var item in res.Data)
{
    Console.WriteLine(item.Url);
}

APIからアシスタントを作成します。

var p = new AssistantCreateParameter();
p.Name = "Legal tutor";
p.Instructions = "You are a personal legal tutor. Write and run code to legal questions based on passed files.";
p.Model = "gpt-4-1106-preview";

p.Tools = new List<ToolObject>();
p.Tools.Add(new ToolObject("code_interpreter"));
p.Tools.Add(new ToolObject("retrieval"));

var res = await client.AssistantCreateAsync(p);
Console.WriteLine(res);

先ほどアップロードしたファイルをアシスタントのファイルとして追加します。

var res = await client.FilesAsync();
foreach (var item in res.Data)
{
    if (item.Purpose == "assistants")
    {
        var res1 = await cl.AssistantFileCreateAsync(id, item.Id);
    }
}

処理を行うにはRunを実行することになります。その実行結果のRunとStepを取得します。

var threadId = "thread_xxxxxxxxxxxx";
var res = await client.RunsAsync(threadId);
foreach (var item in res.Data)
{
    var res1 = await cl.RunStepsAsync(threadId, item.Id);
    foreach (var step in res1.Data)
    {
        if (step.Step_Details != null)
        {
            Console.WriteLine(step.Step_Details.GetDescription());
        }
    }
}

アシスタントAPIをstreamで処理する方法です。

var assistantId = "your assistant Id";
var threadId = "your thread Id";
if (threadId.Length == 0)
{
    var res = await cl.ThreadCreateAsync();
    threadId = res.Id;
}
{
    var p = new MessageCreateParameter();
    p.Thread_Id = threadId;
    p.Role = "user";
    p.Content = "Hello! I want to know how to use OpenAI assistant API to get stream response.";
    var res = await cl.MessageCreateAsync(p);
}
var runId = "";
{
    var p = new RunCreateParameter();
    p.Assistant_Id = assistantId;
    p.Thread_Id = threadId;
    p.Stream = true;
    var result = new AssistantMessageStreamResult();
    await foreach (string text in cl.RunCreateStreamAsync(p, result, CancellationToken.None))
    {
        Console.Write(text);
    }
    Console.WriteLine();
    // You can get each server sent event value by these property.
    Console.WriteLine(JsonConvert.SerializeObject(result.Thread));
    Console.WriteLine(JsonConvert.SerializeObject(result.Run));
    Console.WriteLine(JsonConvert.SerializeObject(result.RunStep));
    Console.WriteLine(JsonConvert.SerializeObject(result.Message));

}

#各クラスのアーキテクチャー設計
メインとなるクラスは以下になります。
OpenAIClient
XXXParameter
OpenAIClient.XXXAsync
XXXResponse
RestApiResponse
QueryParameter

##OpenAIClient
このクラスはAPIのエンドポイントをメソッドとして持っています。

インテリセンスで簡単に確認できます。

必須パラメータのみ渡すメソッドのオーバーロードを用意してあります。このメソッドを呼ぶのがAPIを実行する一番簡単な方法です。

var res = await cl.AudioTranscriptionsAsync("GoodMorningItFineDayToday.mp3"
    , new MemoryStream(File.ReadAllBytes("D:\\Data\\Dev\\GoodMorningItFineDayToday.mp3"))
    , "whisper-1");

OpenAIのAPIのエンドポイントは3種類に分類できます。JSONとFormDataとStreamingです。
通常のAPIはJSON、ファイルのアップロードはFormData、レスポンスのリアルタイム受信がStreamingです。

Json endpoint

Form-data endpoint

Stream endpoint

その3種類に対応するメソッドをSendJsonAsync, SendFormDataAsync, GetStreamAsync として用意してあります。これらのメソッドはそれぞれのエンドポイントへの呼び出しとレスポンスの処理を適切に実行します。

パラメーターオブジェクトを渡すことで各APIを実行できます。.

var p = new AudioTranscriptionsParameter();
p.SetFile("GoodMorningItFineDayToday.mp3", new MemoryStream(File.ReadAllBytes("D:\\Data\\Dev\\GoodMorningItFineDayToday.mp3")));
p.Model = "whisper-1";
var res = await cl.SendFormDataAsync<AudioTranscriptionsParameter, AudioTranscriptionsResponse>(p, CancellationToken.None);

しかしこれらのメソッドはレスポンスの型を適切にセットする必要があり、やや使い勝手が悪いです。なのでパラメータ名とほぼ同じ名前のメソッドを用意してあります。

var p = new AudioTranscriptionsParameter();
p.SetFile("GoodMorningItFineDayToday.mp3", new MemoryStream(File.ReadAllBytes("D:\\Data\\Dev\\GoodMorningItFineDayToday.mp3")));
p.Model = "whisper-1";
var res = await cl.AudioTranscriptionsAsync(p);

##XXXParameter
Parameterクラスは各エンドポイントに渡すための値を保持するためのクラスです。

例えばアシスタントの作成のエンドポイントは以下です。

AssistantCreateParameterクラスは以下のようにデザインされています。

/// <summary>
/// Create an assistant with a model and instructions.
/// <seealso href="https://api.openai.com/v1/assistants">https://api.openai.com/v1/assistants</seealso>
/// </summary>
public partial class AssistantCreateParameter : RestApiParameter, IRestApiParameter
{
    string IRestApiParameter.HttpMethod { get; } = "POST";
    /// <summary>
    /// ID of the model to use. You can use the List models API to see all of your available models, or see our Model overview for descriptions of them.
    /// </summary>
    public string Model { get; set; } = "";
    /// <summary>
    /// The name of the assistant. The maximum length is 256 characters.
    /// </summary>
    public string? Name { get; set; }
    /// <summary>
    /// The description of the assistant. The maximum length is 512 characters.
    /// </summary>
    public string? Description { get; set; }
    /// <summary>
    /// The system instructions that the assistant uses. The maximum length is 32768 characters.
    /// </summary>
    public string? Instructions { get; set; }
    /// <summary>
    /// A list of tool enabled on the assistant. There can be a maximum of 128 tools per assistant. Tools can be of types code_interpreter, retrieval, or function.
    /// </summary>
    public List<ToolObject>? Tools { get; set; }
    /// <summary>
    /// A list of file IDs attached to this assistant. There can be a maximum of 20 files attached to the assistant. Files are ordered by their creation date in ascending order.
    /// </summary>
    public List<string>? File_Ids { get; set; }
    /// <summary>
    /// Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
    /// </summary>
    public object? Metadata { get; set; }

    string IRestApiParameter.GetApiPath()
    {
        return $"/assistants";
    }
    public override object GetRequestBody()
    {
        return new {
            model = this.Model,
            name = this.Name,
            description = this.Description,
            instructions = this.Instructions,
            tools = this.Tools,
            file_ids = this.File_Ids,
            metadata = this.Metadata,
        };
    }
}

これらのParameterクラスは全てOpenAIのドキュメントページのHTMLから自動生成されています。もし興味があれば自動生成のコードはここにあります。 https://github.com/higty/higlabo/tree/master/Net7/HigLabo.OpenAI.CodeGenerator

##OpenAIClient.XXXAsync
これらのメソッドも自動生成です。4つのメソッドが自動生成されており、簡単にエンドポイントを呼び出すことが可能です。

AssistantCreateエンドポイントを呼び出すメソッドが以下のように生成されています。

public async ValueTask<AssistantCreateResponse> AssistantCreateAsync(string model)
public async ValueTask<AssistantCreateResponse> AssistantCreateAsync(string model, CancellationToken cancellationToken)
public async ValueTask<AssistantCreateResponse> AssistantCreateAsync(AssistantCreateParameter parameter)
public async ValueTask<AssistantCreateResponse> AssistantCreateAsync(AssistantCreateParameter parameter, CancellationToken cancellationToken)

他のエンドポイントも同様です。
本質的にはメソッドは2種類に分類されます。

まず最初に説明するのは必須パラメータのみを渡すメソッドです。これは必要最低限のパラメーターだけを渡す形になるので一番簡単に呼び出せるメソッドになります。

public async ValueTask<AssistantCreateResponse> AssistantCreateAsync(string model)

もう一つは全てのパラメーターを指定可能なメソッドです。Parameterオブジェクトを作成し、それをメソッドに渡します。

var p = new AssistantCreateParameter();
p.Name = "Legal tutor";
p.Instructions = "You are a personal legal tutor. Write and run code to legal questions based on passed files.";
p.Model = "gpt-4-1106-preview";

p.Tools = new List<ToolObject>();
p.Tools.Add(new ToolObject("code_interpreter"));
p.Tools.Add(new ToolObject("retrieval"));

var res = await cl.AssistantCreateAsync(p);

ParameterオブジェクトにはAPIの全ての値が指定できるようになっているので自分が指定したい条件でAPIを実行することが可能です。

##XXXResponse
Responseクラスはエンドポイントから受信したデータを表現するクラスです。

RetrieveAssistantエンドポイントからは以下のようなレスポンスが返ってきます。

そこでこのレスポンスのデータを保持するAssistantObjectResponseクラスを作成しました。
※このクラスは自動生成ではなく手動で作ってあります。

public class AssistantObjectResponse: RestApiResponse
{
    public string Id { get; set; } = "";
    public int Created_At { get; set; }
    public DateTimeOffset CreateTime
    {
        get
        {
            return new DateTimeOffset(DateTime.UnixEpoch.AddSeconds(this.Created_At), TimeSpan.Zero);
        }
    }
    public string Name { get; set; } = "";
    public string Description { get; set; } = "";
    public string Model { get; set; } = "";
    public string Instructions { get; set; } = "";
    public List<ToolObject> Tools { get; set; } = new();
    public List<string>? File_Ids { get; set; }
    public object? MetaData { get; set; }

    public override string ToString()
    {
        return $"{this.Id}\r\n{this.Name}\r\n{this.Instructions}";
    }
}

このクラスからレスポンスの各値を取得できます。

##RestApiResponse
時々、リクエストやレスポンスのヘッダーなどの値を調べたい時があります。
RestApiResponseクラスにはAPIの実行時のリクエストやレスポンスのデータが保持されています。

public abstract class RestApiResponse : IRestApiResponse
{
    object? IRestApiResponse.Parameter
    {
        get { return _Parameter; }
    }
    HttpRequestMessage IRestApiResponse.Request
    {
        get { return _Request!; }
    }
    string IRestApiResponse.RequestBodyText
    {
        get { return _RequestBodyText; }
    }
    HttpStatusCode IRestApiResponse.StatusCode
    {
        get { return _StatusCode; }
    }
    Dictionary<String, String> IRestApiResponse.Headers
    {
        get { return _Headers; }
    }
    string IRestApiResponse.ResponseBodyText
    {
        get { return _ResponseBodyText; }
    }
}

IRestApiResponseにキャストすると各プロパティの値を取得できます。

var p = new AssistantCreateParameter();
p.Name = "Legal tutor";
p.Instructions = "You are a personal legal tutor. Write and run code to legal questions based on passed files.";
p.Model = "gpt-4-1106-preview";

var res = await cl.AssistantCreateAsync(p);
var iRes = res as RestApiResponse;
var responseText = iRes.ResponseBodyText;
Dictionary<string, string> responseHeaders = iRes.Headers;
var parameter = iRes.Parameter as AssistantCreateParameter;

例えばエラーのあったリクエストとレスポンスをログに記録する、などといった用途に使えるでしょう。

##QueryParameter
いくつかのエンドポイントではクエリ文字列でフィルタする対象を指定したり、ページ番号を指定したりすることが可能です。

QueryParameterクラスを使用することで条件を指定できます。

var p = new MessagesParameter();
p.Thread_Id = "thread_xxxxxxxxxxxx";
p.QueryParameter.Order = "asc";

#まとめ
まだ公式のライブラリが最新のAPIに対応してないので作ってみました。バグなどありましたら連絡ください。
Twitter: https://twitter.com/higty
GitHub: https://github.com/higty

Discussion