Open6

Siv3D v0.6.8 における OpenAI API サンプル

Ryo SuzukiRyo Suzuki

OpenAI API 利用の流れ

  1. データ + API キーからなるリクエストを OpenAI サーバに送る。
  2. OpenAI が JSON で返答(内容によっては時間がかかる)
  3. JSON から必要な部分を抽出

Siv3D では OpenAI::~ の関数を使うことで簡単に扱える。

OpenSiv3D で標準対応する OpenAI API

  • Chat: 一連の会話に続くメッセージを回答する (v0.6.7)
  • Image: 英語での説明に基づいた画像を返す (v0.6.7)
  • Embedding: 単語や文章を意味に基づいた埋め込みベクトルに変換する (v0.6.8)

料金

OpenAI が返答するとき、API キーの発行者に対して下記の従量課金。
トークンは入出力の合計。英語 750 語がおよそ 1000 トークン。
日本語は 1 文字で 1 トークン前後。

API 料金
Chat (gpt-3.5-turbo) $0.002 / 1K tokens
Image (1024×1024) $0.020 / image
Image (512×512) $0.018 / image
Image (256×256) $0.016 / image
Embedding (Ada) $0.0004 / 1K tokens

https://openai.com/pricing

Ryo SuzukiRyo Suzuki

API キー

OpenAI の API キーは "sk-" から始まる数十文字の文字列。
例: "sk-12345689abcdefghi..."

API キーを安全に保存する

コードをコミット・公開したときに API キーが流出しないよう、開発中は API キーを環境変数に設定し、環境変数を読み取るコードで API キーを取得するようにする。

const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

環境変数の設定方法

Windows


再起動が必要な場合がある。

macOS

ターミナルで次のようなコマンドを入力する。
launchctl setenv <環境変数のキー> "<環境変数の値>"

launchctl setenv MY_OPENAI_API_KEY "sk-12345689abcdefghi..."

再起動すると設定は失われる。

環境変数が設定されたかの確認

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	// 環境変数から API キーを取得する
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	// API キーを表示する
	Print << API_KEY;

	while (System::Update())
	{

	}
}
Ryo SuzukiRyo Suzuki

チュートリアル 1. Chat

1.1 Chat の基本

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	// 環境変数から API キーを取得する
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	// 回答を String で得る
	const String answer = OpenAI::Chat::Complete(API_KEY, U"日本で一番高い山は?");

	Print << answer;

	while (System::Update())
	{

	}
}

OpenAI からのレスポンスが返ってくるまで関数は制御を返さない(待ちが発生する)点に注意。待っている間に別のことをしたい場合は 1.3 で扱う非同期版を使う。

1.2 テキストボックスから質問する(単一のメッセージ、同期)

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	Scene::SetBackground(ColorF{ 0.8, 0.9, 1.0 });

	const Font font{ FontMethod::MSDF, 48, Typeface::Medium };

	// 環境変数から API キーを取得する
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	// テキストボックスの中身
	TextEditState textEditState;

	// 回答を格納する変数
	String answer;

	while (System::Update())
	{
		// テキストボックスを表示する
		SimpleGUI::TextBox(textEditState, Vec2{ 40, 40 }, 600);

		if (SimpleGUI::Button(U"送信", Vec2{ 660, 40 }, 80,
			(not textEditState.text.isEmpty()))) // テキストボックスが空でないときだけボタンを有効にする
		{
			// 質問文
			const String input = textEditState.text;

			// 回答文
			answer = OpenAI::Chat::Complete(API_KEY, input);
		}

		// 回答がある場合
		if (answer)
		{
			font(answer).draw(20, Rect{ 40, 100, 720, 600 }, ColorF{ 0.25 });
		}
	}
}

1.3 非同期処理

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	Scene::SetBackground(ColorF{ 0.8, 0.9, 1.0 });

	const Font font{ FontMethod::MSDF, 48, Typeface::Medium };

	// 環境変数から API キーを取得
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	// テキストボックスの中身
	TextEditState textEditState;

	// 非同期タスク
	AsyncHTTPTask task;

	// 回答を格納する変数
	String answer;

	while (System::Update())
	{
		// テキストボックスを表示する
		SimpleGUI::TextBox(textEditState, Vec2{ 40, 40 }, 600);

		if (SimpleGUI::Button(U"送信", Vec2{ 660, 40 }, 80,
			((not textEditState.text.isEmpty()) // テキストボックスが空でなく
				&& (not task.isDownloading())))) // タスクの実行中でないときだけボタンを有効にする
		{
			// 前回の回答を消去する
			answer.clear();

			// 質問文
			const String input = textEditState.text;

			// タスクを作成する
			task = OpenAI::Chat::CompleteAsync(API_KEY, input);
		}

		// ChatGPT の応答を待つ間はローディング画面を表示する
		if (task.isDownloading())
		{
			Circle{ Scene::Center(), 50 }.drawArc((Scene::Time() * 120_deg), 300_deg, 4, 4);
		}

		// 非同期処理が完了し、正常なレスポンスである場合
		if (task.isReady() && task.getResponse().isOK())
		{
			// 非同期処理の結果を取得する
			answer = OpenAI::Chat::GetContent(task.getAsJSON());
		}

		// 回答がある場合
		if (answer)
		{
			font(answer).draw(20, Rect{ 40, 100, 720, 600 }, ColorF{ 0.25 });
		}
	}
}

1.4 入力 UI の工夫

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	Scene::SetBackground(ColorF{ 0.8, 0.9, 1.0 });

	const Font font{ FontMethod::MSDF, 48, Typeface::Medium };

	// 環境変数から API キーを取得
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	// テキストボックスの中身
	TextEditState textEditState;

	// 非同期タスク
	AsyncHTTPTask task;

	// 回答を格納する変数
	String answer;

	while (System::Update())
	{
		// テキストボックスを表示する
		SimpleGUI::TextBox(textEditState, Vec2{ 40, 40 }, 240);

		if (SimpleGUI::Button(U"に登場する敵モンスターを生成", Vec2{ 300, 40 }, 360,
			((not textEditState.text.isEmpty()) // テキストボックスが空でなく
				&& (not task.isDownloading())))) // タスクの実行中でないときだけボタンを有効にする
		{
			// 前回の回答を消去する
			answer.clear();

			// 質問文
			const String input = (U"RPG ゲームで" + textEditState.text + U"に登場する敵モンスターを 1 種類考えてください。");

			// タスクを作成する
			task = OpenAI::Chat::CompleteAsync(API_KEY, input);
		}

		// ChatGPT の応答を待つ間はローディング画面を表示する
		if (task.isDownloading())
		{
			Circle{ Scene::Center(), 50 }.drawArc((Scene::Time() * 120_deg), 300_deg, 4, 4);
		}

		// 非同期処理が完了し、正常なレスポンスである場合
		if (task.isReady() && task.getResponse().isOK())
		{
			// 非同期処理の結果を取得する
			answer = OpenAI::Chat::GetContent(task.getAsJSON());
		}

		// 回答がある場合
		if (answer)
		{
			font(answer).draw(20, Rect{ 40, 100, 720, 600 }, ColorF{ 0.25 });
		}
	}
}

1.5 回答を JSON 形式で得る

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	Scene::SetBackground(ColorF{ 0.8, 0.9, 1.0 });

	const Font font{ FontMethod::MSDF, 48, Typeface::Medium };

	// 環境変数から API キーを取得
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	// テキストボックスの中身
	TextEditState textEditState;

	// 非同期タスク
	AsyncHTTPTask task;

	// 回答を格納する変数
	String answer;

	while (System::Update())
	{
		// テキストボックスを表示する
		SimpleGUI::TextBox(textEditState, Vec2{ 40, 40 }, 240);

		if (SimpleGUI::Button(U"に登場する敵モンスターを生成", Vec2{ 300, 40 }, 360,
			((not textEditState.text.isEmpty()) // テキストボックスが空でなく
				&& (not task.isDownloading())))) // タスクの実行中でないときだけボタンを有効にする
		{
			// 前回の回答を消去する
			answer.clear();

			// 質問文
			String input = (U"RPG ゲームで" + textEditState.text + U"に登場する敵モンスターを 1 種類考えてください。\n");
			input += U"出力は次のような JSON 形式で、日本語で出力してください。回答に JSON データ以外を含まないで下さい。\n";
			input += UR"({ "name": "敵の名前", "desc" : "説明" })"; // UR"()" は生文字列リテラル https://cpprefjp.github.io/lang/cpp11/raw_string_literals.html

			// タスクを作成する
			task = OpenAI::Chat::CompleteAsync(API_KEY, input);
		}

		// ChatGPT の応答を待つ間はローディング画面を表示する
		if (task.isDownloading())
		{
			Circle{ Scene::Center(), 50 }.drawArc((Scene::Time() * 120_deg), 300_deg, 4, 4);
		}

		// 非同期処理が完了し、正常なレスポンスである場合
		if (task.isReady() && task.getResponse().isOK())
		{
			// 非同期処理の結果を取得する
			answer = OpenAI::Chat::GetContent(task.getAsJSON());
		}

		// 回答がある場合
		if (answer)
		{
			font(answer).draw(20, Rect{ 40, 100, 720, 600 }, ColorF{ 0.25 });
		}
	}
}

1.6 表示の工夫

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

// モンスターの情報
struct Monster
{
	// 名前
	String name;

	// 説明
	String desc;
};

void Main()
{
	Scene::SetBackground(ColorF{ 0.8, 0.9, 1.0 });

	const Font font{ FontMethod::MSDF, 48, Typeface::Medium };

	// 環境変数から API キーを取得
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	// テキストボックスの中身
	TextEditState textEditState;

	// 非同期タスク
	AsyncHTTPTask task;

	// モンスターの情報
	Optional<Monster> monster;

	while (System::Update())
	{
		// テキストボックスを表示する
		SimpleGUI::TextBox(textEditState, Vec2{ 40, 40 }, 240);

		if (SimpleGUI::Button(U"に登場する敵モンスターを生成", Vec2{ 300, 40 }, 360,
			((not textEditState.text.isEmpty()) // テキストボックスが空でなく
				&& (not task.isDownloading())))) // タスクの実行中でないときだけボタンを有効にする
		{
			// 前回の回答を消去する
			monster.reset();

			// 質問文
			String input = (U"RPG ゲームで" + textEditState.text + U"に登場する敵モンスターを 1 種類考えてください。\n");
			input += U"出力は次のような JSON 形式で、日本語で出力してください。回答に JSON データ以外を含まないで下さい。\n";
			input += UR"({ "name": "敵の名前", "desc" : "説明" })"; // UR"()" は生文字列リテラル https://cpprefjp.github.io/lang/cpp11/raw_string_literals.html

			// タスクを作成する
			task = OpenAI::Chat::CompleteAsync(API_KEY, input);
		}

		// ChatGPT の応答を待つ間はローディング画面を表示する
		if (task.isDownloading())
		{
			Circle{ Scene::Center(), 50 }.drawArc((Scene::Time() * 120_deg), 300_deg, 4, 4);
		}

		// 非同期処理が完了し、正常なレスポンスである場合
		if (task.isReady() && task.getResponse().isOK())
		{
			// 非同期処理の結果を取得する
			const String answer = OpenAI::Chat::GetContent(task.getAsJSON());

			const JSON json = JSON::Parse(answer);

			// 指定したフォーマットになっているかを確認する
			if ((json.hasElement(U"name") && json[U"name"].isString())
				&& (json.hasElement(U"desc") && json[U"desc"].isString()))
			{
				// モンスターの情報を JSON から取得する
				monster = Monster{
					.name = json[U"name"].getString(),
					.desc = json[U"desc"].getString(),
				};
			}
		}

		// モンスターの情報がある場合
		if (monster)
		{
			font(monster->name).draw(36, Vec2{ 40, 100 }, ColorF{ 0.25 });

			font(monster->desc).draw(20, Rect{ 40, 160, 720, 540 }, ColorF{ 0.25 });
		}
	}
}

複雑な JSON の妥当性を確認する場合、JSONValidator も使える。

Ryo SuzukiRyo Suzuki

チュートリアル 2. Chat(複数の会話)

role と message をペアにした Array<std::pair<String, String> で会話を送る。

role 説明
system 前提条件や役割などを AI に指示
user ユーザの発言
assistant AI の発言

2.1 複数のメッセージ

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	// 環境変数から API キーを取得
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	Print << OpenAI::Chat::Complete(API_KEY, {
		{ U"system", U"回答はできる限り簡潔にして、末尾に!を付けてください。" },
		{ U"user", U"白い食べ物は?" },
		{ U"assistant", U"豆腐!" },
		{ U"user", U"では黒いのは?" } });

	while (System::Update())
	{
	
	}
}

Ryo SuzukiRyo Suzuki

チュートリアル 3. Image

3.1 同期

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	Window::Resize(512, 512);

	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	const Texture texture{ OpenAI::Image::Create(API_KEY,
		U"There are tall mountains in the distance. The sky is clear.",
		OpenAI::ImageSize512) };

	while (System::Update())
	{
		texture.draw();
	}
}

3.2 非同期

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

void Main()
{
	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	Array<Texture> textures;

	AsyncTask task = OpenAI::Image::CreateAsync(API_KEY, U"Mount Fuji has erupted, and volcanic ash is falling on Tokyo.", 4, OpenAI::ImageSize256);

	while (System::Update())
	{
		if (task.isValid())
		{
			Circle{ Scene::Center(), 50 }.drawArc(Scene::Time() * 120_deg, 300_deg, 4, 4);
		}

		if (task.isReady())
		{
			for (const auto& image : task.get())
			{
				textures << Texture{ image };
			}
		}

		for (size_t i = 0; i < textures.size(); ++i)
		{
			const double x = (i % 2) * 256.0;
			const double y = (i / 2) * 256.0;
			textures[i].draw(x, y);
		}
	}
}
Ryo SuzukiRyo Suzuki

チュートリアル 4. Embedding (エンベディング)

4.1 埋め込みベクトルとコサイン類似度


4.2 類似する文章の検索

# include <Siv3D.hpp> // OpenSiv3D v0.6.8

struct Text
{
	String text;

	Array<float> embedding;

	float cosineSimilarity = 0.0f;
};

bool Init(const String API_KEY, Array<Text>& texts)
{
	for (auto& text : texts)
	{
		String error;

		// OpenAI Embeddings API で文章の埋め込みベクトルを取得
		text.embedding = OpenAI::Embedding::Create(API_KEY, text.text, error);

		if (not text.embedding)
		{
			Print << error;

			return false;
		}
	}

	return true;
}

void Main()
{
	Window::Resize(1280, 720);

	Scene::SetBackground(ColorF{ 0.92 });

	const String API_KEY = EnvironmentVariable::Get(U"MY_OPENAI_API_KEY");

	const Font font{ FontMethod::MSDF, 48, Typeface::Medium };

	Array<Text> texts =
	{
		{ U"公園。市街地などに設けられた公共施設としての庭園や遊園地。" },
		{ U"天気。ある場所の、ある時刻の気象状態。気温・湿度・風・雲量などを総合した状態。" },
		{ U"会議。関係者が集まって相談をし、物事を決定すること。" },
		{ U"水泳。スポーツや娯楽として水中を泳ぐこと。" },
		{ U"寿司。酢飯に生鮮魚介の切り身を乗せた料理。" },
		{ U"携帯電話。無線を用いて長距離通信のできる小型の移動電話。" },
		{ U"医者。病人の診察・治療を職業とする人。" },
		{ U"電車。駆動用電動機を装置し、架線あるいは軌道から得る電気を動力源として走行する鉄道車両。" },
		{ U"森林。樹木、特に高木が群生して大きな面積を占めている所。" },
		{ U"火曜日。月曜日と水曜日の間にある週の1日。" },
		{ U"大使館。特命全権大使が駐在国において公務を執行する公館。" },
		{ U"窃盗。人の物をぬすむこと。また、その人。" },
		{ U"地震。地球内部の急激な変動による振動が四方に伝わり大地が揺れる現象。" },
		{ U"手紙。用事などを記して、人に送る文書。" },
		{ U"睡眠。ねむること。ねむり。" },
	};

	AsyncTask initTask = Async(Init, String{ API_KEY }, std::ref(texts));

	TextEditState textEditState;

	float maxCosineSimilarity = 0.0f, minCosineSimilarity = 1.0f;

	AsyncHTTPTask task;

	while (System::Update())
	{
		if (initTask.isValid())
		{
			Circle{ Scene::Center(), 40 }.drawArc(Scene::Time() * 90_deg, 270_deg, 5);

			font(U"テキストの埋め込みベクトルを計算しています。事前に計算しておくことで実行時の処理を省略できます。").drawAt(22, Scene::Center().movedBy(0, 100), ColorF{ 0.11 });

			if (initTask.isReady())
			{
				if (not initTask.get())
				{
					Print << U"埋め込みベクトルの計算に失敗。";
				}
			}

			continue;
		}

		SimpleGUI::TextBox(textEditState, Vec2{ 40, 40 }, 1000);

		if (SimpleGUI::Button(U"検索", Vec2{ 1060, 40 }, 80, (not textEditState.text.isEmpty()) && (not task.isDownloading())))
		{
			task = OpenAI::Embedding::CreateAsync(API_KEY, textEditState.text);
		}

		if (task.isReady() && task.getResponse().isOK())
		{
			const Array<float> inputEmbedding = OpenAI::Embedding::GetVector(task.getAsJSON());

			maxCosineSimilarity = 0.0f; minCosineSimilarity = 1.0f;

			for (auto& text : texts)
			{
				text.cosineSimilarity = OpenAI::Embedding::CosineSimilarity(inputEmbedding, text.embedding);
				maxCosineSimilarity = Max(maxCosineSimilarity, text.cosineSimilarity);
				minCosineSimilarity = Min(minCosineSimilarity, text.cosineSimilarity);
			}
		}

		if (not task.isDownloading())
		{
			for (int32 i = 0; i < texts.size(); ++i)
			{
				const float cosineSimilarity = texts[i].cosineSimilarity;

				const Rect rect{ 40, (100 + i * 38), 1180, 36 };

				// 最も類似度が高いものを強調表示
				rect.draw((cosineSimilarity == maxCosineSimilarity) ? ColorF{ 1.0, 1.0, 0.75 } : ColorF{ 1.0 });

				// コサイン類似度を 0.0 ~ 1.0 に変換
				const double t = Math::Map(cosineSimilarity, minCosineSimilarity, maxCosineSimilarity, 0.0, 1.0);

				RectF{ rect.pos, (50 * t), rect.h }.stretched(0, -2).draw(Colormap01F(t, ColormapType::Turbo));

				// 文章とコサイン類似度を表示
				font(texts[i].text).draw(22, Arg::leftCenter = rect.leftCenter().movedBy(80, 0), ColorF{ 0.11 });
				font(cosineSimilarity).draw(18, Arg::leftCenter = rect.leftCenter().movedBy(1080, 0), ColorF{ 0.11 });
			}
		}
	}

	if (initTask.isValid())
	{
		initTask.wait();
	}
}