【iOS】OpenAI APIを使ってアプリを作る 

# はじめに
流行りのOpenAI APIを使ったアプリ開発を体験したくチュートリアル的な形でデモアプリを作ってみました。

# 環境
Xcode 14.1

# 内容
デモアプリはSwiftUIで作っていきます
<img width="500" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/ad860802-3400-23da-55f9-b1cdd9c1b3f3.png">
[Swift Package Index](https://swiftpackageindex.com/)でOpenAI APIが使えるライブラリを検索してこちらが1番良さそうでしたので使っていきます

https://swiftpackageindex.com/adamrushy/OpenAISwift

新しいプロジェクトを作成したら、さっそく先ほどのライブラリを追加していきます
`Pacakage Dependencies`の➕ボタンより
<img width="400" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/fb97c1d5-28f9-2a62-bbc7-f57d4c87a8f2.png">

`https://github.com/adamrushy/OpenAISwift.git`で検索
`OpenAISwift`を追加します

<img width="500" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/d63fd418-097c-aecc-143d-71b1cd74ef25.png">

これでPackage Dependenciesに追加されました
<img width="300" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/d2f62208-5669-0cd1-ed0c-9f68d8bd686a.png">



#### OpenAIのAPI keyを発行する
OpenAIのアカウントページのUserのAPI Keysより発行しておきます

<img width="500" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/80660aeb-1940-428a-7ba4-0e4d77cead41.png">

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

#### `ContentView`にOpen AIの処理を追加

OpenAIのAPIを使う処理だけで見れば
ざっくり以下3点追加することで実現できました

- `import OpenAISwift`
- `OpenAISwift(authToken:)`
- `openAI.sendCompletion(with:)`

入力と回答を表示するUIの実装も含めて全体のContentViewはこちらです
（ちょっと余計な実装も含めてますが、ざっくりこんな感じでいきます）

```diff_swift: ContentView
import SwiftUI
+ import OpenAISwift

struct ContentView: View {
    @FocusState var focus
    @State var loading: Bool = false
    @State var currentQuestion: String = ""
    @State var responseList: [AIResponse] = []
    @State var error: Error?

+   let openAI = OpenAISwift(authToken: "your_OpenAI_API_Key")

    struct AIResponse: Identifiable {
        let id: UUID = UUID()
        let question: String
        let answer: String
    }

    var body: some View {
        ZStack {
            VStack(spacing: 0) {
                List {
                    ForEach(responseList) { response in
                        Section {
                            Text(response.answer)
                                .textSelection(.enabled)
                        } header: {
                            Text(response.question)
                                .onTapGesture {
                                    currentQuestion = response.question
                                }
                        }
                    }
                }
                .listStyle(.grouped)
                Divider()
                HStack {
                    TextField("Ask me anything", text: $currentQuestion, axis: .vertical)
                        .lineLimit(2...10)
                        .border(Color(UIColor.separator))
                        .focused($focus)
                    Button {
                        askAI(question: currentQuestion)
                    } label: {
                        Image(systemName: "brain.head.profile")
                    }
                    .disabled(currentQuestion.isEmpty || loading)
                }
                .padding()
            }
            .onTapGesture {
                focus = false
            }
            if loading {
                ProgressView("asking...")
                    .progressViewStyle(.circular)
            }
        }
        .alert(error?.localizedDescription ?? "エラーが発生しました", isPresented: .constant(error != nil), actions: {
            Button("OK", role: .cancel) {
                error = nil
            }
        })
    }

    func askAI(question: String) {
        Task {
            loading = true
            do {
+               let result = try await openAI.sendCompletion(
+                   with: question,
+                   model: .gpt3(.davinci),   // optional `OpenAIModelType`
+                   maxTokens: 16,          // optional `Int?`
+                   temperature: 1            // optional `Double?`
+               )
                loading = false
                guard let answer = result.choices.first?.text else { throw NSError() }
                let response = AIResponse(question: question, answer: answer)
                responseList.insert(response, at: 0)
            } catch {
                loading = false
                self.error = error
            }
        }
    }
}
```

<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/a494f986-9c05-7802-e4e9-e86e71643f5d.png">

```: レスポンス
OpenAI<TextResult>(
object: "text_completion", 
model: Optional("text-davinci-003"), 
choices: [OpenAISwift.TextResult(text: "\n\n・Paella（パエリア）\n")]
)
```

`openAI.sendCompletion(with:)`の部分はREADMEのまま追加してますが、これだと回答の表示がほとんどされません

`openAI.sendCompletion(with:)`のコメントとOpenAI APIのドキュメントを見ると

```swift
    /// Send a Completion to the OpenAI API
    /// - Parameters:
    ///   - prompt: The Text Prompt
    ///   - model: The AI Model to Use. Set to `OpenAIModelType.gpt3(.davinci)` by default which is the most capable model
    ///   - maxTokens: The limit character for the returned response, defaults to 16 as per the API
    ///   - temperature: Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. Defaults to 1
    /// - Returns: Returns an OpenAI Data Model
    @available(swift 5.5)
    @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
    public func sendCompletion(with prompt: String, model: OpenAIModelType = .gpt3(.davinci), maxTokens: Int = 16, temperature: Double = 1) async throws -> OpenAI<TextResult> {
        return try await withCheckedThrowingContinuation { continuation in
            sendCompletion(with: prompt, model: model, maxTokens: maxTokens, temperature: temperature) { result in
                continuation.resume(with: result)
            }
        }
    }
```

`model: Optional("text-davinci-003")`でレスポンスが返ってきてたのでこれっぽい？　

[<img width="500" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/58110186-5c69-107d-0e04-601650103591.png">](https://platform.openai.com/docs/models/gpt-3-5)

`maxTokens`を変更することで表示できる文字数を増やせるようです
~~特にリリースする予定もないので最大の値を入れて動かしてみます~~

~~maxTokensの値は4073にしたらいけましたが、4074にしたらエラーになりました🤔~~

---
【2023/04/30追記】
このmaxTokensの考え方を勘違いしていたので訂正します🙏
使っているモデルのMax Tokensは、4,092なので、リクエストとレスポンスの合計が超えるとエラーになるようです。つまり、4074でエラーになったのはリクエストが残り分を占めていたので発生しただけでした。
このまま4073にしてしまうと、少し長い質問は受け付けてくれなくなりました。

ちなみにこの場合のエラーはよく分からなかったですが

`
decodingError(error: Swift.DecodingError.keyNotFound(CodingKeys(stringValue: "object", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: \"object\", intValue: nil) (\"object\").", underlyingError: nil)))
`

レスポンスをdecodeしている箇所で止め
`
po JSONSerialization.jsonObject(with: success, options: []) as? [String: Any]
`
で値を確認したところ

```swift: OpenAISwift.swift
        makeRequest(request: request) { result in
            switch result {
            case .success(let success):
                do {
                    let res = try JSONDecoder().decode(OpenAI<TextResult>.self, from: success)
                    completionHandler(.success(res))
                } catch {
                    completionHandler(.failure(.decodingError(error: error)))
                }
            case .failure(let failure):
                completionHandler(.failure(.genericError(error: failure)))
            }
        }
```

`
This model's maximum context length is 4097 tokens, however you requested 4162 tokens (72 in your prompt; 4090 for the completion). Please reduce your prompt; or completion length.
`
とエラー文言が確認できました


---

<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/791f8987-857f-4de8-2dea-066d781d53d7.png">

maxTokensの値を修正して、再度動作確認！

```swift
    let result = try await openAI.sendCompletion(
        with: question,
        maxTokens: 2000
    )
```



<img width="300" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/165c6a24-df02-8cab-f387-68b578f5e187.png">

```:レスポンス
OpenAI<TextResult>(
object: "text_completion", 
model: Optional("text-davinci-003"), 
choices: [OpenAISwift.TextResult(
text: "\n\n- パエリア（Paella）\n- ジャモーサ（Gazpacho）\n- チキン・パパリラ（Pollo al ajillo）\n- タンベジータ・ラテ（Tortilla de patatas）\n- ケチャップ・ブイキャップ（Chorizo a la Sidra）\n- オリーヴ・オイル・ブレッド（Pan con Aceite）\n- ナキューロ（Nacionales）\n- クレア・レモン（Crema de limon）\n- ピーナッツ・ディップ（Crayonicas）\n- ビーフ・テツェッド（Carne mechada）")]
)
OpenAI<TextResult>(
object: "text_completion", 
model: Optional("text-davinci-003"), 
choices: [OpenAISwift.TextResult(
text: "\n\n・パエリア\n・チリリブ\n・パプリカ\n・トマトのサラダ\n・マルセロ・アルモド\n・パイナップルのタルタル\n・クーパンダース\n・パスタ・アルフレド\n・ジンファンデル\n・アルボ・ガーリャ")]
)
```
いい感じで動くようになりました👏

#　トークンの消費量
最後に、今回ざっくり動くところまでOpenAi APIを触ってみましたが、どのくらいお金がかかったのか見てみます

<img width="500" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/2cada506-b8cf-5d13-486b-f228b9957cbd.png">
<img width="500" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/80e1f7e0-b669-3757-c8c7-6591566ecbc4.png">

$0.15でした
このあたりはまだまだ理解が足りてないですが、工夫次第で少ない消費にすることも出来そうな気がしました

### おまけ（2023/04/30追記）

一問一答だけでなく、チャットもできるようにしてみました。

<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/c0ecf807-675f-53c0-a318-d695669cd708.png">

`openAI.sendChat(with:)`に`ChatMessage`を渡すと前回の回答を踏まえた答えがもらえます


```swift
    private func chatAI(question: String) {
        Task {
            loading = true
            do {
                var chat: [ChatMessage] = [ChatMessage(role: .system, content: "あなたはとても優秀なアシスタントで様々な質問に丁寧な言葉で正しく回答します")]
                let chatHistory: [ChatMessage] = responseList
                    .map({ res in
                        [
                            ChatMessage(role: .assistant, content: res.answer),
                            ChatMessage(role: .user, content: res.question)
                        ]
                    })
                    .flatMap { $0 }
                    .reversed()
                chat.append(contentsOf: chatHistory)
                chat.append(ChatMessage(role: .user, content: question))
                let result = try await openAI.sendChat(
                        with: chat,
                        model: .chat(.chatgpt)
                    )
                loading = false
                guard let answer = result.choices.first?.message else {
                    throw NSError()
                }
                let response = AIResponse(question: question, answer: answer.content)
                responseList.insert(response, at: 0)
            } catch {
                loading = false
                self.error = error
            }
        }
    }
```

ただし`ChatMessage`が空の状態（初回など）でリクエストをすると
`[] is too short - 'messages'`とエラーが返ってきたので
初回は`openAI.sendCompletion`を使うようにしました。



# まとめ
動くものは作れましたが、ほとんどOpenAI APIを理解せずに動いてしまったところもあるので、ここから引き続きデモアプリを通して学んでいこうと思います

# 参考
https://dev.classmethod.jp/articles/open-ai-swift-example/

https://github.com/adamrushy/OpenAISwift

https://platform.openai.com/docs/models/overview