C# で ChatGPTのAPIを叩く

## はじめに

何かと(それはもう過剰なほどに)話題のChatGPT。~~乗るしか無いよなぁ、このビッグウェーブに~~ 
ただ有料版はかなりいい値段してちょっと払う気にならないので、APIの方叩けばいいんじゃない？ということで叩いてみました。
本当にただそれだけのクソ記事です。

## 知っておくべき情報まとめ

- API利用料金は 0.002ドル / 1000トークン
  - 日本語テキストなら「1文字1トークン」の認識でOK
  - 以前の会話履歴(後述)、事前指示、ユーザーの質問文、AIの応答、全部都度課金される
- HTTP Restなエンドポイントがあるのでほぼどの言語からでも叩ける
  - めちゃくちゃ「普通」。エンドポイントは実質一個だけ。 
- APIは (というかAIは) あくまで `static` , ステートレス , 冪等
  - 都度結果が変わるとしたら渡してるシード値(乱数)のせい
  - 会話のコンテキストを読むのは以前の会話履歴全部送りつけてるから

APIのリファレンスに必要なことはほぼすべて書いてあるのでそれを読めばたたけます。

https://platform.openai.com/docs/api-reference/chat

利用料金とか特徴などは以下のページで紹介がある

https://openai.com/blog/introducing-chatgpt-and-whisper-apis

## 登録

まずは、アカウント登録してAPIトークンを取得します。
openAIのアカウントを作成すると、3ヶ月有効な18ドル分の無料枠が付きます。ただリリース当日くらいからChatGPTで遊んでたので自分の場合はもう有効期限が切れます。
無料枠がなければ、月末にクレジットカードから課金されます。が、大した利用料ではない上に、上限額の設定・ほぼリアルタイムの料金表示ができるので安心。

以下にアクセスしてキーを取得します。自分の場合コンテンツブロッカーが干渉したので、キーが見えない人は一旦止めるといいかも。
キーは再表示できないので、ファイルに保存するなりメモっておく。し忘れたら再生成して古いのを消せばよい。

https://platform.openai.com/account/api-keys

あとは、同じ画面から利用料金の確認や上限設定ができるので一通り見ておくとよい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510423/12f6c2f8-348f-0d11-ecf8-a6b540b33d40.png)


## コーディング

API定義のドキュメントに従って、C# の型定義とHTTPリクエスト処理する関数を書きます。
~~ChatGPTに書かせようとしたら書いてくれなかったので自分で書きました~~

コード全体は GitHub においておいたのでそっち見て下さい。

https://github.com/2427dkusiro/ChatGPTCLI

以下では適当にかいつまんで説明します。

### リクエスト

意外とパラメータが多いですが、とりあえずデフォルトで試します。
最低限セットするのが、`Model` と `Message` です。
モデルにはモデル名を設定します。ドキュメントから引っ張ってくるか、モデル名取得APIを叩いて確認します。今回は決め打ちします。
メッセージには、AI に考慮させるすべての会話履歴を突っ込みます。まぁコード見てもらった方が早いです。
メッセージの送信者のロールが 3 つ定義されていて、以下のような感じです。

```cs
/// <summary>
/// 役割を表現します。
/// </summary>
public enum Roles
{
	/// <summary>
	/// システム。AI に説明を与えます。
	/// </summary>
	System,

	/// <summary>
	/// ユーザー。
	/// </summary>
	User,

	/// <summary>
	/// アシスタント。これは AI です。
	/// </summary>
	Assistant
}
```

基本ユーザーとアシスタントのやりとりになります。最初に状況設定を入れるときにシステムを使います。


### レスポンス

コードのとおりです。`Choices` の最初の要素を見る、という使い方をすることが多くなると思います。
`record` 型って便利ですね。

```cs
/// <summary>
/// ChatGPTのレスポンスを表現します。
/// </summary>
/// <param name="Id">レスポンスの ID。</param>
/// <param name="ObjectName">レスポンスを作成した環境の名前。</param>
/// <param name="CreatedTimeStamp">レスポンスの作成時刻。</param>
/// <param name="ModelName">使用したモデル。</param>
/// <param name="Usage">使用料金の情報。</param>
/// <param name="Choices">AI が生成した応答の候補。</param>
public record ChatGPTResponse([property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("object")] string ObjectName, [property: JsonPropertyName("created")] long CreatedTimeStamp, [property: JsonPropertyName("model")] string ModelName, [property: JsonPropertyName("usage")] ChatGPTUsage Usage, [property: JsonPropertyName("choices")] ChatGPTChoice[] Choices);
```

`Usage` はこんな感じになっていて、実際問い合わせが何トークンだったのか教えてくれます。

```cs
/// <summary>
/// ChatGPT の使用料金情報を表現します。
/// </summary>
/// <param name="PromptTokens">ユーザーが入力したプロンプトのトークン数。</param>
/// <param name="CompletionTokens">AI が生成したメッセージのトークン数。</param>
/// <param name="TotalTokens">合計のトークン数。これに基づいて課金されます。</param>
public record ChatGPTUsage([property: JsonPropertyName("prompt_tokens")] int PromptTokens, [property: JsonPropertyName("completion_tokens")] int CompletionTokens, [property: JsonPropertyName("total_tokens")] int TotalTokens);
```

### HTTPリクエスト

飛ばす先はこんな感じです。

```cs
private static readonly string endPoint = "https://api.openai.com/v1/chat/completions";
```

単なるCRUDのAPIではなく、AIの生成待ちが入るので、タイムアウトを調整する必要があるかもしれません。

```cs
private readonly HttpClient _client;

// 略
        _client = new()
		{
			Timeout = TimeSpan.FromSeconds(apiTimeout)
		};
```

Jsonシリアライザを調整します。`request` は `ChatGPTRequest` 型です。

```cs
var options = new JsonSerializerOptions
{
	Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
	Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
	DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var str = JsonSerializer.Serialize(request, options);
```

リクエストを投げます。`Authorization` ヘッダにベアラートークンを設定するのを忘れずに。

```cs
var content = new StringContent(str, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json"));
var httpReq = new HttpRequestMessage(HttpMethod.Post, endPoint);
httpReq.Headers.Add("Authorization", $"Bearer {credential}");
httpReq.Content = content;

CancellationTokenSource cancellationTokenSource = new();
HttpResponseMessage resp;
try
{
	resp = await _client.SendAsync(httpReq, cancellationTokenSource.Token);
}
```

あとはレスポンスを読みます。

```cs
if (resp.IsSuccessStatusCode)
{
	var body = await resp.Content.ReadAsStringAsync();
	var obj = JsonSerializer.Deserialize<ChatGPTResponse>(body, options);

	return obj ?? throw new InvalidOperationException($"Unexpected response: {body}");
}
```

会話履歴を管理するラッパークラスを作っておくと便利なので `ChatGPTClient` クラスとして定義しておきました。

## 実装してみる

作ったラッパーを実際に使ってチャットボットを実装します。
最初にシステムプロンプトを設定します。

```cs
var client = new ChatGPTClient();
client.AddSystemPrompt("ここではチャットGPTならぬ「キャットGPT」としてふるまい、語尾を「にゃー」か「にゃん」にし、全体的にかわいらしい文体で応答してください。");
```

あとは適当にリクエストを投げます。
改行を調整する処理を入れておきます。

```cs
var resp = await client.SendMessageAsync(input);

var lines = resp.Choices.First().Message.Content.Split(new string[] { "\n", "\r\n" }, StringSplitOptions.TrimEntries);
var msg = string.Join(Environment.NewLine, lines.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => "AI > " + x));
Console.WriteLine(msg);
```

こんな感じです。
できたものがこちら。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510423/1c6913d7-4dcb-bb60-60c5-cd220cf74e94.png)

先程のリポジトリをクローンして、各自のAPIトークンを `__credential.secret` という名前のテキストファイルで保存すると同じものが動くと思います。
~~かわいいね~~

## さいごに

とりあえず簡単に叩けることがわかったので、なにか作ってみようと思います。