Skip to content

Commit

Permalink
OpenAI Component (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
PSchmiedmayer committed May 21, 2023
1 parent 1747a62 commit 8ffcccf
Show file tree
Hide file tree
Showing 15 changed files with 323 additions and 59 deletions.
3 changes: 3 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ authors:
- family-names: "Schmiedmayer"
given-names: "Paul"
orcid: "https://orcid.org/0000-0002-8607-9148"
- family-names: "Ravi"
given-names: "Vishnu"
orcid: "https://orcid.org/0000-0003-0359-1275"
title: "SpeziML"
doi: 10.5281/zenodo.7538165
url: "https://github.com/StanfordSpezi/SpeziML"
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ SpeziML contributors
====================

* [Paul Schmiedmayer](https://github.com/PSchmiedmayer)
* [Vishnu Ravi](https://github.com/vishnuravi)
19 changes: 15 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,27 @@ let package = Package(
.iOS(.v16)
],
products: [
.library(name: "SpeziML", targets: ["SpeziML"])
.library(name: "SpeziOpenAI", targets: ["SpeziOpenAI"])
],
dependencies: [
.package(url: "https://github.com/MacPaw/OpenAI", .upToNextMinor(from: "0.2.1")),
.package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.5.0")),
.package(url: "https://github.com/StanfordSpezi/SpeziStorage", .upToNextMinor(from: "0.3.1"))
],
targets: [
.target(
name: "SpeziML"
name: "SpeziOpenAI",
dependencies: [
.product(name: "OpenAI", package: "OpenAI"),
.product(name: "Spezi", package: "Spezi"),
.product(name: "SpeziLocalStorage", package: "SpeziStorage"),
.product(name: "SpeziSecureStorage", package: "SpeziStorage")
]
),
.testTarget(
name: "SpeziMLTests",
name: "SpeziOpenAITests",
dependencies: [
.target(name: "SpeziML")
.target(name: "SpeziOpenAI")
]
)
]
Expand Down
23 changes: 0 additions & 23 deletions Sources/SpeziML/SpeziML.docc/SpeziML.md

This file was deleted.

19 changes: 0 additions & 19 deletions Sources/SpeziML/SpeziML.swift

This file was deleted.

81 changes: 81 additions & 0 deletions Sources/SpeziOpenAI/OpenAIComponent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// This source file is part of the Stanford Spezi open source project
//
// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//


import OpenAI
import Spezi
import SpeziLocalStorage
import SpeziSecureStorage
import SwiftUI


/// `OpenAIComponent` is a module responsible for to coordinate the interactions with the OpenAI GPT API.
public class OpenAIComponent<ComponentStandard: Standard>: Component, ObservableObject, ObservableObjectProvider {
@Dependency private var localStorage: LocalStorage
@Dependency private var secureStorage: SecureStorage

/// The OpenAI GPT Model type that is used to interact with the OpenAI API
@AppStorage(OpenAIConstants.modelStorageKey) public var openAIModel: Model = .gpt3_5Turbo
private var defaultAPIToken: String?

/// The API token used to interact with the OpenAI API
public var apiToken: String? {
get {
try? secureStorage.retrieveCredentials(OpenAIConstants.credentialsUsername, server: OpenAIConstants.credentialsServer)?.password
}
set {
objectWillChange.send()
if let newValue {
try? secureStorage.store(
credentials: Credentials(username: OpenAIConstants.credentialsUsername, password: newValue),
server: OpenAIConstants.credentialsServer
)
} else {
try? secureStorage.deleteCredentials(OpenAIConstants.credentialsUsername, server: OpenAIConstants.credentialsServer)
}
}
}


/// Initializes a new instance of `OpenAIGPT` with the specified API token and OpenAI model.
///
/// - Parameters:
/// - apiToken: The API token for the OpenAI API.
/// - openAIModel: The OpenAI model to use for querying.
public init(apiToken: String? = nil, openAIModel model: Model? = nil) {
if UserDefaults.standard.object(forKey: OpenAIConstants.modelStorageKey) == nil {
self.openAIModel = openAIModel
}

defaultAPIToken = apiToken
}


public func configure() {
if self.apiToken == nil, let defaultAPIToken {
self.apiToken = defaultAPIToken
}
}


/// Queries the OpenAI API using the provided messages.
///
/// - Parameters:
/// - messages: A collection of chat messages used in the conversation.
///
/// - Returns: The content of the response from the API.
public func queryAPI(withChat chat: [Chat]) async throws -> AsyncThrowingStream<ChatStreamResult, Error> {
guard let apiToken, !apiToken.isEmpty else {
throw OpenAIError.noAPIToken
}

let openAIClient = OpenAI(apiToken: apiToken)
let query = ChatQuery(model: openAIModel, messages: chat)
return openAIClient.chatsStream(query: query)
}
}
14 changes: 14 additions & 0 deletions Sources/SpeziOpenAI/OpenAIConstants.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// This source file is part of the Stanford Spezi open source project
//
// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//


enum OpenAIConstants {
static let modelStorageKey = "OpenAIGPT.Model"
static let credentialsServer = "openapi.org"
static let credentialsUsername = "OpenAIGPT"
}
14 changes: 14 additions & 0 deletions Sources/SpeziOpenAI/OpenAIError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// This source file is part of the Stanford Spezi open source project
//
// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//


/// An error that can appear from an API call to the OpenAI API.
public enum OpenAIError: Error {
/// There was no OpenAI API token provided.
case noAPIToken
}
53 changes: 53 additions & 0 deletions Sources/SpeziOpenAI/SpeziOpenAI.docc/SpeziOpenAI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ``SpeziOpenAI``

<!--
#
# This source file is part of the Stanford Spezi open source project
#
# SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)
#
# SPDX-License-Identifier: MIT
#
-->

Module to interact with the OpenAI API to interact with GPT-based large language models (LLMs).

## Configuration

```
class ExampleDelegate: SpeziAppDelegate {
override var configuration: Configuration {
Configuration(standard: /* ... */) {
OpenAIComponent()
// ...
}
}
}
```

You can provide a default API token or model configuration to the OpenAI component's ``OpenAIComponent/init(apiToken:openAIModel:)`` initializer in the configuration.
The choice of model and the API key are persisted across application launches.


## Usage

The ``OpenAIComponent`` can subsequentially be used in a SwiftUI View using the environment dependency injection mechanism.

```
struct ExampleOpenAIView: View {
@EnvironmentObject var openAI: OpenAIComponent</* ... */>
// ...
}
```

The ``OpenAIComponent``'s ``OpenAIComponent/apiToken`` and ``OpenAIComponent/openAIModel`` can be accessed and changed at runtime.
The ``OpenAIComponent/queryAPI(withChat:)`` function allows the interaction with the GPT-based OpenAI models.


## Types

### Open AI GPT

- ``OpenAIGPT``
- ``OpenAIGPTError``
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@
// SPDX-License-Identifier: MIT
//

@testable import SpeziML
@testable import SpeziOpenAI
import XCTest


final class SpeziMLTests: XCTestCase {
func testSpeziML() throws {
let speziML = SpeziML()
XCTAssertEqual(speziML.stanford, "Stanford University")
final class SpeziOpenAITests: XCTestCase {
func testSpeziOpenAITests() throws {
XCTAssert(true)
}
}
28 changes: 28 additions & 0 deletions Tests/UITests/TestApp/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// This source file is part of the SpeziML open-source project
//
// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SpeziOpenAI
import SwiftUI
import XCTSpezi


struct ContentView: View {
@EnvironmentObject var openAI: OpenAIComponent<TestAppStandard>


var body: some View {
Text("Your token is: \(openAI.apiToken ?? "")")
Text("Your choice of model is: \(openAI.openAIModel)")
Button("Test Token Change") {
openAI.apiToken = "New Token"
}
Button("Test Model Change") {
openAI.openAIModel = .gpt4
}
}
}
7 changes: 5 additions & 2 deletions Tests/UITests/TestApp/TestApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
// SPDX-License-Identifier: MIT
//

import SpeziML
import SwiftUI


@main
struct UITestsApp: App {
@UIApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate


var body: some Scene {
WindowGroup {
Text(SpeziML().stanford)
ContentView()
.spezi(appDelegate)
}
}
}
20 changes: 20 additions & 0 deletions Tests/UITests/TestApp/TestAppDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// This source file is part of the SpeziML open-source project
//
// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import Spezi
import SpeziOpenAI
import XCTSpezi


class TestAppDelegate: SpeziAppDelegate {
override var configuration: Configuration {
Configuration(standard: TestAppStandard()) {
OpenAIComponent()
}
}
}
24 changes: 23 additions & 1 deletion Tests/UITests/TestAppUITests/TestAppUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,41 @@
//

import XCTest
import XCTestExtensions


class TestAppUITests: XCTestCase {
override func setUpWithError() throws {
try super.setUpWithError()

continueAfterFailure = false

let app = XCUIApplication()
app.deleteAndLaunch(withSpringboardAppName: "TestApp")
}


func testSpeziML() throws {
let app = XCUIApplication()

XCTAssert(app.staticTexts["Your token is: "].waitForExistence(timeout: 2))
XCTAssert(app.staticTexts["Your choice of model is: gpt-3.5-turbo"].waitForExistence(timeout: 2))

app.buttons["Test Token Change"].tap()
XCTAssert(app.staticTexts["Your token is: New Token"].waitForExistence(timeout: 2))

app.buttons["Test Model Change"].tap()
XCTAssert(app.staticTexts["Your choice of model is: gpt-4"].waitForExistence(timeout: 2))

app.terminate()
app.launch()
XCTAssert(app.staticTexts["Stanford University"].waitForExistence(timeout: 0.1))

XCTAssert(app.staticTexts["Your token is: New Token"].waitForExistence(timeout: 2))
XCTAssert(app.staticTexts["Your choice of model is: gpt-4"].waitForExistence(timeout: 2))

app.deleteAndLaunch(withSpringboardAppName: "TestApp")

XCTAssert(app.staticTexts["Your token is: New Token"].waitForExistence(timeout: 2))
XCTAssert(app.staticTexts["Your choice of model is: gpt-3.5-turbo"].waitForExistence(timeout: 2))
}
}
Loading

0 comments on commit 8ffcccf

Please sign in to comment.