GoとOpenAI APIでシンプルなChat API作ってみた

# 概要
タイトル通り、GoとOpenAIのAPIを使ってChat APIを作ってみようと思います。
Goも生成AIもど素人なので、ご指摘ご助言ありましたらいただけると非常にありがたいです。

なんちゃってハンズオンみたいな感じを目指して書いていきます。

### 使用技術
- Go（Gin)
- Docker
- OpenAI

### 構成を考える
こんな感じで作っていこうと思います。
```
├── cmd
│   └── main.go
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
└── pkg
    ├── config
    │   └── config.go            // 環境変数担当
    ├── domain
    │   └── usecase
    │       └── chat_usecase.go　　　
    ├── infra
    │   └── openai
    │       ├── client.go　　　　　// OpenAIとの疎通担当
    │       └── model.go
    ├── controller
    │   └── chat_controller.go　  // リクエスト処理担当
    └── usecase
        └── chat_usecase.go　　　　// ロジック担当
```

# 目次
アプリケーションのセットアップなど基本的なところから順に書いていますが、手っ取り早くOpenAIとの接続部分が見たい方は [5. OpenAIと接続する](#5-openaiと接続する) をご覧ください

[1. OpenAIのAPIキーを準備する](#1-openaiのapiキーを準備する)
[2. Goアプリケーションのセットアップ](#2-goアプリケーションのセットアップ)
[3. Dockerの導入とホットリロード環境の構築](#3-dockerの導入とホットリロード環境の構築)
[4. 環境変数を設定する](#4-環境変数を設定する)
[5. OpenAIと接続する](#5-openaiと接続する)
[6. ロジックとコントローラーを実装する](#6-ロジックとコントローラーを実装する)
[7. 今まで作ってきたものを繋げる](#7-今まで作ってきたものを繋げる)
[8. 動作確認](#8-動作確認)
[9. 今回作ったもの](#9-今回作ったもの)

# 1. OpenAIのAPIキーを準備する
### やること
- OpenAIのAPIキーを取得

### Open AIのアカウントを作成・ログイン
下記のサイトにアクセスしてアカウントを作成します（すでにOpenAIのアカウントを持っている方はログイン）

https://platform.openai.com/

### API Keyの発行
2023年12月現在のUIだと、サイドバーにある🔒アイコンからAPIキーのページにアクセスできます。
![スクリーンショット 2023-12-02 21.12.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615347/8e2555c9-a0e2-4587-ce6c-f8d2cbe6d8a9.png)

`Generate new secret key`ボタンをクリックして、APIキーを発行します
※APIキーの作成には電話認証が必要です。
![スクリーンショット 2023-12-02 21.14.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615347/f995e867-7ce3-e1c2-a29d-0102312e3a8f.png)

任意の名前（無名でも作成できます）をつけて作成します。
作成に成功すると下記のようなモーダルが表示されるので、この時に表示されるキーをコピーし控えておきます。
__※モーダル上にも記載がありますが、このモーダルを閉じると再度キーを閲覧することができません。忘れたり紛失したりした場合は再度作り直しが必要です。__
![スクリーンショット 2023-12-02 21.23.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615347/a58e6f27-65db-9720-9b19-9ec63c5ed593.png)

これでAPIキーの準備はOKです。

### 余談：APIの利用料金について
APIの利用料金は、主に使用する「トークン」の数に基づいています。
「トークン」は送信する単語数等をもとに計算されます。

OpenAIに課金登録していない場合でも、アカウント作成後5ドル分（2023年現在）の無料枠が付与されているので、特にお金を払わなくてもAPIを試しすことができます。

使用するAIのモデル等によってもトークン数の制限や料金モデルも異なりますので、詳しく知りたい方は下記をご覧ください

https://platform.openai.com/account/limits

また、トークン数を計算しリアルタイムに表示するサイトもOpenAIから提供されているので、気になる方はこちらもどうぞ（この記事の最後に使ってみます）

https://platform.openai.com/tokenizer

# 2. Goアプリケーションのセットアップ
### やること
- Ginを使ってWebサーバーを立ち上げる

### プロジェクトを初期化
```bash
$ cd openai-go-sample
$ go mod init ${モジュール名}
```
モジュール名の部分は自由に設定できますが、私はリポジトリのURLと一致させています。（`github.com/ユーザー名/リポジトリ名`）

すると下記のように `go.mod` ファイルが作成されるかと思います。
```
module github.com/shunsukenagashima/openai-go-sample

go 1.21.3
```

`go.mod` についての詳細な説明は省きますが、依存関係の管理やバージョン管理を行なうためのファイルです。

## Webサーバーを作る
まずはシンプルなWebサーバーを[Gin](https://pkg.go.dev/github.com/gin-gonic/gin)というフレームワークを使って構築していきます

#### Ginのインストール
```bash
$ go get github.com/gin-gonic/gin
```


#### シンプルなWebサーバーの構築
下記はアクセスすると `Hello World` を返すWebサーバーの一例です。

```go:cmd/main.go
package main

import "github.com/gin-gonic/gin"

func main() {
	r := gin.Default()

	r.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Hello World",
		})
	})

	r.Run()
}
```

下記コマンドで実行してみましょう
```bash
$ go run cmd/main.go
```

こんな感じでログが出れば起動できているので、
```
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
```

localhost:8080にアクセスすると期待通りの結果がかえってきています。
![スクリーンショット 2023-12-03 0.32.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615347/fbb07e69-cbd7-3a19-548c-34a3444d3bb0.png)

これで基本的なセットアップは完了です。

# 3. Dockerの導入とホットリロード環境の構築
ローカルではホットリロード環境(ファイルの更新が即時反映)を使用したいので、 [air](https://github.com/cosmtrek/air) というOSSを使用します。

### airのインストール
```bash
$ go install github.com/cosmtrek/air@latest
```
※他にも方法はありますが、go1.8以降はgo installを使うことが推奨されているようです（詳しくはGithub等をご確認ください）

### airの初期化
airの実行には、`.air.toml`という設定ファイルが必要ですが、下記コマンドで作成します

```bash
$ air init
```
※パスが未設定で上記コマンドが失敗する場合は、パスの設定が必要です（「Go パスを通す」などで検索すると出てくるかも）
とはいえ、dokcerでairを実行する分には設定は不要なので下記の設定ファイルをそのまま使用しても大丈夫です。

すると、下記のような設定ファイルが作成されます
※私の環境では `cmd` に `main.go`を配置しているので、その部分だけ修正しています。
```diff_toml:.air.toml
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
  args_bin = []
  bin = "./tmp/main"
- cmd = "go build -o ./tmp/main ."
+ cmd = "go build -o ./tmp/main ./cmd"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  poll = false
  poll_interval = 0
  post_cmd = []
  pre_cmd = []
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  main_only = false
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = false
  keep_scroll = true

```

### Dockerの設定
下記の通りDockerfileを作成します
※今回はローカルでの確認にとどめるためdev用のコンテナしか用意していません。

```Dockerfile:Dockerfile
FROM golang:1.21.3 as dev

WORKDIR /app

RUN go install github.com/cosmtrek/air@latest

CMD ["air"]
```
Docker Composeの設定もしておきましょう
先ほど作成したDockerfileのdevコンテナを指定しています。

```docker-compose.yml
version: '3'

services:
  app:
    build:
      context: .
      args:
        - target=dev
    volumes:
      - .:/app
    ports:
      - 8080:8080
```

### 起動してみる
ここまできたら、Dockerから先ほど作成したWebサーバーが起動できるか確認してみます。
```bash
$ docker compose up
```

`http://localhost:8080`にアクセスして、先ほどと同じ画面が表示されればOKです。


# 4. 環境変数を設定する
APIキーなどのような情報を管理するための環境変数周りの設定をしていきます。
Goにおいては、標準ライブラリの`os`パッケージを使って管理することもできますが、今回はサードパーティライブラリの`caarlos0/env`パッケージを使用したいと思います。

### パッケージのインストール
2023年現在最新はv10になっているようなので、最新をインストールします。
```bash
$ go get github.com/caarlos0/env/v10
```

### 環境変数ファイルを用意してDockerに渡す
プロジェクトルートに`.env`ファイルを用意して事前に準備しておいたAPIキーを設定しておきます。
`AI_MODEL`は、名の通りどのAIモデルを使うかを指定します。gpt4など他のモデルの指定も可能です。
```.env
OPEN_AI_KEY=YOUR_API_KEY
AI_MODEL=gpt-3.5-turbo-0613
```

docker-compose.ymlに環境変数ファイルを渡します
```diff_docker:docker-compose.yml
version: '3'

services:
  app:
    build:
      context: .
      args:
        - target=dev
+   env_file
+     - .env 
    volumes:
      - .:/app
    ports:
      - 8080:8080
```

### Goでcaarlos0/envを使用
下記のように設定して、型安全に環境変数を扱えるようにしておきます。
```go:pkg/config/config.go
package config

import "github.com/caarlos0/env/v10"

type Config struct {
	OpenAIKey string `env:"OPEN_AI_KEY,required"`
	AIModel   string `env:"AI_MODEL,required"`
}

func New() (*Config, error) {
	c := &Config{}
	if err := env.Parse(c); err != nil {
		return nil, err
	}
	return c, nil
}

```

これで環境周りの設定は完了したので、次は実際にOpenAIと接続していきたいと思います。

# 5. OpenAIと接続する
いよいよ本番ですね。
下記のようなステップを踏んで実装を進めたいと思います。
(1) リクエスト・レスポンス内容の確認
(2) OpenAIと疎通するためのクライアントの作成

<br/>

まずドキュメントを見て必要な情報を確認してみます。

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

#### エンドポイント
下記を利用します
`POST https://api.openai.com/v1/chat/completions`

### リクエスト
Example Requestを見てみます
```
curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant."
      },
      {
        "role": "user",
        "content": "Hello!"
      }
    ]
  }'
\
```

必須情報は下記の二つ
#### messages (array)
roleとcontentを含んだ配列ですね。
contentがメッセージ、roleはそのメッセージの役割といったところでしょうか。
roleには4種類あるようで、
- `user`
ユーザーからの入力
- `system`
AIの設定（たとえば人格とか）
- `assistant`
AIからの回答
※AIからの返答を含んだこれまでの会話を送ることで、文脈を踏まえた回答が得られるようです
- `tool`
詳細不明でした。わかり次第追記したいと思います


を選ぶことができます。

今回は `user`と`system`あたりを送って確認してみることにします。


#### model
どのモデルのAIを使用するのかを指定します


### レスポンス
Example Responseを見ていきます
```
{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1677652288,
  "model": "gpt-3.5-turbo-0613",
  "system_fingerprint": "fp_44709d6fcb",
  "choices": [{
    "index": 0,
    "message": {
      "role": "assistant",
      "content": "\n\nHello there, how may I assist you today?",
    },
    "finish_reason": "stop"
  }],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 12,
    "total_tokens": 21
  }
}
```

`choices`の中に、AIからの返答があるみたいですね。
`usage`の中には使用したトークン量が入っているようです。

上記を参考にしつつ、OpenAIと疎通するクライアントを作っていきます。


### OpenAIクライアントを作成する
infra層にOpenAIのクライアントを作成します

#### モデルの定義
先ほど調べたリクエストとレスポンスを参考に下記のようにmodelを定義しました
```go:pkg/infra/openai/model.go
package openai

type OpenAICommunicator interface {
	SendMessage(prompt []*Prompt) (*ChatResponse, error)
}

type Role string

const (
	System    Role = "system"
	User      Role = "user"
	Assistant Role = "assistant"
)

type Prompt struct {
	Role    Role   `json:"role"`
	Content string `json:"content"`
}

type openAIRequest struct {
	Model    string    `json:"model"`
	Messages []*Prompt `json:"messages"`
}

type openAIResponse struct {
	ID      string   `json:"id"`
	Choices []choice `json:"choices"`
	Usage   usage    `json:"usage"`
}

type choice struct {
	Index        int    `json:"index"`
	Message      Prompt `json:"message"`
	FinishReason string `json:"finish_reason"`
}

type usage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
	TotalTokens      int `json:"total_tokens"`
}

type ChatResponse struct {
	Content string `json:"content"`
	Usage   usage  `json:"usage"`
}

```
AIからのレスポンスと使用したトークン量を`ChatResponse`として返すようにしておきます。


#### クライアントの作成
APIと疎通するためのクライアントを作っていきます
```go:pkg/infra/openai/client.go
package openai

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

type OpenAIClient struct {
	apiKey     string
	model      string
	url        string
	httpClient *http.Client
}

func NewOpenAIClient(apiKey string, model string, httpClient *http.Client) OpenAICommunicator {
	if httpClient == nil {
		httpClient = &http.Client{}
	}
	return &OpenAIClient{
		apiKey:     apiKey,
		model:      model,
		url:        "https://api.openai.com/v1/chat/completions",
		httpClient: httpClient,
	}
}
```
`apiKey`と`model`を指定して、Open AIのクライアントを作成し、使用するイメージです
`url`も可変にしておいたほうがよさそうですが、今回はひとまずベタ書きしています。

では次に、実際にOpenAIのAPIにリクエストを送り、その返答を返すための関数を実装したいと思います。

#### SendMessage関数の実装
下記はAPIにリクエストを送り、レスポンスからメッセージを取り出して返却する関数の実装例です。
```go:pkg/infra/openai/client.go
func (c *OpenAIClient) SendMessage(prompts []*Prompt) (*ChatResponse, error) {
	reqBody := &openAIRequest{
		Model:    c.model,
		Messages: prompts,
	}

	reqJSON, err := json.Marshal(reqBody)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest("POST", c.url, bytes.NewBuffer(reqJSON))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("API request failed with status %v: %s", resp.StatusCode, body)
	}

	var openAIResp openAIResponse
	if err := json.Unmarshal(body, &openAIResp); err != nil {
		return nil, err
	}

	if len(openAIResp.Choices) == 0 {
		return nil, fmt.Errorf("API response does not contain any choices")
	}

	chatResp := &ChatResponse{
		Content: openAIResp.Choices[0].Message.Content,
		Usage:   openAIResp.Usage,
	}

	return chatResp, nil
}

```
Example Requestにもあった通り、Authorizatonヘッダーにapiキーを付与して送信しています。

早速このクライアントを使ってロジック部分の実装を行なっていきます。

# 6. ロジックとコントローラーを実装する
ここでは下記の2を実装していきます
- ロジックを管理するusecase層
- リクエストを処理するcontroller層
### usecaseの実装
まずはインタフェースを書いていきます。
といっても、SendMessage関数だけを持つシンプルなものです。
```go:pkg/domain/usecase/chat_usecase.go
package usecase

import "github.com/shunsukenagashima/openai-go-sample/pkg/infra/openai"

type ChatUsecase interface {
	SendMessage(message string) (*openai.ChatResponse, error)
}

```

実装部分は下記の通りです。

```go:pkg/usecase/chat_usecase.go
package usecase

import "github.com/shunsukenagashima/openai-go-sample/pkg/infra/openai"

type ChatUsecase struct {
	openaiClient openai.OpenAICommunicator
}

func NewChatUsecase(openaiClient openai.OpenAICommunicator) *ChatUsecase {
	return &ChatUsecase{
		openaiClient: openaiClient,
	}
}

func (u *ChatUsecase) SendMessage(message string) (*openai.ChatResponse, error) {
	userPrompt := &openai.Prompt{
		Role:    openai.User,
		Content: message,
	}

	systemPrompt := &openai.Prompt{
		Role:    openai.System,
		Content: "あなたは関西人です。コテコテの関西弁で話してください。",
	}

	var prompts []*openai.Prompt
	prompts = append(prompts, userPrompt)
	prompts = append(prompts, systemPrompt)

	resp, err := u.openaiClient.SendMessage(prompts)
	if err != nil {
		return nil, err
	}

	return resp, nil
}
```
ここでは2つのpromptを使ってAPIにリクエストを送信しています。

- `userPrompt`
user roleのプロンプトです。ユーザーが入力した内容をAIに送ります。

- `systemPrompt`
system roleのプロンプトです。AIには関西人になりきって返答してもらいます。

次はcontrollerを実装していきましょう

### controllerの実装
#### リクエストのバリデーション
あまり関係ない部分なのでここまでやらんでも、、と思いつつ、まずリクエストのバリデーション用のライブラリを入れます。
`github.com/go-playground/validator/v10`というライブラリです。

```bash
go get github.com/go-playground/validator/v10
```

#### コントローラの実装
下記を行なっていきます。
- リクエストのバリデーション
- usecase層を使って処理を実行する
```go:pkg/interface/controller/chat_controller.go
package controller

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
	"github.com/shunsukenagashima/openai-go-sample/pkg/domain/usecase"
)

type ChatController struct {
	chatUsecase usecase.ChatUsecase
	validator   *validator.Validate
}

func NewChatController(chatUsecase usecase.ChatUsecase, validator *validator.Validate) *ChatController {
	return &ChatController{
		chatUsecase,
		validator,
	}
}

func (cc *ChatController) SendMessage(ctx *gin.Context) {
	var req struct {
		Message string `json:"message"`
	}

	if err := ctx.BindJSON(&req); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if err := cc.validator.Struct(req); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	resp, err := cc.chatUsecase.SendMessage(req.Message)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	ctx.JSON(http.StatusOK, gin.H{"message": resp.Content, "promptTokens": resp.Usage.PromptTokens})
}

```

あとはmain.goにエンドポイントを追加すれば完了です。

# 7. 今まで作ってきたものを繋げる
これまで実装してきたものを繋げて、`/chat`エンドポイントを作ります。
```go:cmd/main.go
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
	"github.com/shunsukenagashima/openai-go-sample/pkg/config"
	"github.com/shunsukenagashima/openai-go-sample/pkg/infra/openai"
	"github.com/shunsukenagashima/openai-go-sample/pkg/interface/controller"
	"github.com/shunsukenagashima/openai-go-sample/pkg/usecase"
)

func main() {
	http := &http.Client{}

	cfg, err := config.New()
	if err != nil {
		log.Printf("failed to load config: %v", err)
		os.Exit(1)
	}

	log.Print(cfg)

	oc := openai.NewOpenAIClient(cfg.OpenAIKey, cfg.AIModel, http)
	v := validator.New()
	cu := usecase.NewChatUsecase(oc)
	cc := controller.NewChatController(cu, v)

	r := gin.Default()

	r.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Hello World",
		})
	})

	r.POST("/chat", cc.SendMessage)

	r.Run()
}
```
下記を行なっています。
- config（環境変数）の読み込み
- OpenAIクライアントの初期化
- validatorの初期化
- ChatUsecaseの初期化
- ChatControllerの初期化
- `/chat`エンドポイントの追加

これで実装は完了です。

# 8. 動作確認
Postmanを使って動作確認していきます。
※localhostへリクエストを送るためには、Postman Agentを別途インストールする必要があるようです。

![スクリーンショット 2023-12-04 20.11.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615347/d54a8cb4-bcfa-4e63-c433-8c6458055a89.png)

コテコテの関西人が「風に肩凝ってんねん」という表現を使うのかは定かではありませんが、しっかりと関西弁で返してくれてますね。

設定として渡した `system prompt` も動作していることが確認できます。

ちょっと技術的なことも聞いてみましょう。
![スクリーンショット 2023-12-04 20.42.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615347/21b1b188-77c0-5a29-d7b5-352e92ddf5ef.png)

最初と最後だけ関西弁っぽくなってますが、中身は標準語になっちゃってますね。笑

ここら辺はもう少し `system prompt` で調整したりできるかもしれません。

とはいえ最低限の機能は備えているかと思いますので、ひとまず今回はこれで実装完了としたいと思います。

### 余談：トークン量と翻訳
日本語でメッセージを送ると、必然的に使用トークン量が多くなってしまうので、事前に英語に翻訳してから送るなどの対応が必須そうです。

今週の献立を英語と日本語で聞いてみると、、、
![スクリーンショット 2023-12-04 20.54.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615347/01f7543e-af7b-3c81-7843-fedd17f136f2.png)

![スクリーンショット 2023-12-04 20.53.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615347/96597b0f-62a9-ed08-92fa-605de91d1286.png)

これだけ短い文章でも日本語で送ると倍のトークン量なので、長くなればなるほど顕著になりそうですね。


以上。余談でした。

# 9. 今回作ったもの
完成物のリンクを貼っておきます。

https://github.com/ShunsukeNagashima/openai-go-sample


## 終わりに
作ってみると、意外と簡単に組み込めたなーという感想です。
GTPsの登場もあって、シンプルなチャットボットを作っても需要はなさそうですが、
画像生成AI等とも組み合わせつつエンタメ系の領域でなら結構面白いことができるんじゃないかなと思いました。

パッとみた限り今回使ったエンドポイント以外にも色々あるみたいなので、時間がある時にまたいろいろ触ってみたいと思います。

完全に趣味で触ってみた系の記事ですが、どなたかの参考になれば幸いです。
また、記事を読んでいただいてのご指摘ご助言も大歓迎です。