diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 00000000..b82d544a --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,124 @@ +# Continuous Integration (CI) to Build & Test & Coverage & Lint. +# ~~ +name: CI +on: + pull_request: + branches: [ main, '**' ] + push: + branches: [ main ] + +jobs: + validate: + name: Validate Code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup JDK + uses: actions/setup-java@v3 + with: + distribution: corretto + java-version: '11' + cache: 'sbt' + + - name: Validate Code + run: sbt validateCode + + build: + name: Build & Test + needs: [ validate ] + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + scala: [ '2.12.15', '2.13.10', '3.2.2' ] + + steps: + - uses: actions/checkout@v3 + + - name: Setup JDK + uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '8' + cache: 'sbt' + + - name: Build & Test + run: sbt ++${{ matrix.scala }} testWithCoverage + + - name: Upload coverage report (Cobertura) + uses: actions/upload-artifact@v3.1.0 + with: + name: cobertura.xml + path: ${{github.workspace}}/target/scala-2.13/coverage-report/cobertura.xml + + - name: Upload coverage report (HTML) + uses: actions/upload-artifact@v3.1.0 + with: + name: scoverage-report-html + path: ${{github.workspace}}/target/scala-2.13/scoverage-report/ + + optional-build: + name: Build (Optional) + continue-on-error: ${{ matrix.experimental }} + needs: [ validate ] + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + distribution: [ 'corretto' ] + jdk: [ '11' ] + scala: [ '2.12.15', '2.13.10', '3.2.2' ] + experimental: [ false ] + include: + - jdk: '17' + distribution: 'corretto' + scala: '2.13.10' + experimental: true + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup JDK + uses: actions/setup-java@v3 + with: + distribution: ${{ matrix.distribution }} + java-version: ${{ matrix.jdk }} + cache: 'sbt' + + - name: Perform Build / Test + run: sbt ++${{ matrix.scala }} compile test + + coverage: + name: Coverage Report + if: ${{ github.event.pull_request }} + needs: [ build ] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v3 + with: + name: cobertura.xml + + - name: Analyzing coverage report + uses: 5monkeys/cobertura-action@master + with: + path: cobertura.xml + only_changed_files: true + fail_below_threshold: true + show_missing: true + show_line: true + show_branch: true + show_class_names: true + link_missing_lines: true + minimum_coverage: 75 + + ready-to-merge: + name: Ready to Merge + if: ${{ github.event.pull_request }} + needs: [ optional-build, coverage ] + runs-on: ubuntu-latest + steps: + - run: echo 'Ready to merge.' diff --git a/build.sbt b/build.sbt index 8aaca69c..af4935c2 100755 --- a/build.sbt +++ b/build.sbt @@ -10,17 +10,27 @@ ThisBuild / scalaVersion := scala212 ThisBuild / version := "0.3.3" ThisBuild / isSnapshot := false +lazy val commonSettings = Seq( + libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.16", + libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.16" % Test, + libraryDependencies += "org.mockito" %% "mockito-scala-scalatest" % "1.17.14" % Test +) + lazy val core = (project in file("openai-core")) + .settings(commonSettings: _*) lazy val client = (project in file("openai-client")) + .settings(commonSettings: _*) .dependsOn(core) .aggregate(core) lazy val client_stream = (project in file("openai-client-stream")) + .settings(commonSettings: _*) .dependsOn(client) .aggregate(client) lazy val guice = (project in file("openai-guice")) + .settings(commonSettings: _*) .dependsOn(client) .aggregate(client_stream) @@ -44,4 +54,33 @@ ThisBuild / sonatypeCredentialHost := "s01.oss.sonatype.org" ThisBuild / sonatypeRepository := "https://s01.oss.sonatype.org/service/local" -ThisBuild / publishTo := sonatypePublishToBundle.value \ No newline at end of file +ThisBuild / publishTo := sonatypePublishToBundle.value + +addCommandAlias( + "validateCode", + List( + "scalafix", + "scalafmtSbtCheck", + "scalafmtCheckAll", + "test:scalafix", + "test:scalafmtCheckAll" + ).mkString(";") +) + +addCommandAlias( + "formatCode", + List( + "scalafmt", + "scalafmtSbt", + "Test/scalafmt" + ).mkString(";") +) + +addCommandAlias( + "testWithCoverage", + List( + "coverage", + "test", + "coverageReport" + ).mkString(";") +) diff --git a/openai-core/src/test/scala/io/cequence/openaiscala/service/OpenAIServiceWrapperSpec.scala b/openai-core/src/test/scala/io/cequence/openaiscala/service/OpenAIServiceWrapperSpec.scala new file mode 100644 index 00000000..dd46f104 --- /dev/null +++ b/openai-core/src/test/scala/io/cequence/openaiscala/service/OpenAIServiceWrapperSpec.scala @@ -0,0 +1,265 @@ +package io.cequence.openaiscala.service + +import io.cequence.openaiscala.domain.response._ +import io.cequence.openaiscala.domain.{ChatRole, MessageSpec} +import org.mockito.scalatest.MockitoSugar +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.matchers.should +import org.scalatest.wordspec.AnyWordSpecLike + +import java.io.File +import scala.concurrent.Future + +class OpenAIServiceWrapperSpec + extends AnyWordSpecLike + with should.Matchers + with ScalaFutures + with OpenAIServiceConsts + with MockitoSugar { + + "OpenAIServiceWrapper" should { + + val testDate = new java.util.Date(0L) + + val modelInfo = + ModelInfo( + "test-model", + testDate, + owned_by = "test_owner", + root = "test_root", + parent = None, + permission = Array[Permission]() + ) + + val models = Seq(modelInfo) + + val testFile = mock[File] + + val imageInfo = + ImageInfo(created = testDate, Seq[Map[String, String]]()) + + val transcriptResponse = TranscriptResponse("test-response", None) + + class MockWrapper(val underlying: OpenAIService) + extends OpenAIServiceWrapper { + var called: Boolean = false + override protected def wrap[T]( + fun: OpenAIService => Future[T] + ): Future[T] = { + called = true + fun(underlying) + } + + override def close: Unit = {} + } + + def testWrapWith[T](fixture: T)(block: OpenAIService => Future[T]): Unit = { + val mockService = mock[OpenAIService] + val wrapper = new MockWrapper(mockService) + when(block(mockService)).thenReturn(Future.successful(fixture)) + val result = block(wrapper) + result.futureValue shouldBe fixture + whenReady(result) { _ => + wrapper.called shouldBe true + } + } + + "call wrap for listModels" in { + testWrapWith(models) { _.listModels } + } + + "call wrap for retrieveModel" in { + val response: Option[ModelInfo] = Some(modelInfo) + testWrapWith(response) { _.retrieveModel(modelInfo.id) } + } + + "call wrap for createCompletion" in { + val completion = TextCompletionResponse( + id = "test-id", + created = testDate, + model = "test-model", + choices = Seq[TextCompletionChoiceInfo](), + usage = None + ) + testWrapWith(completion) { + _.createCompletion("test-prompt", DefaultSettings.CreateCompletion) + } + } + + "call wrap for createChatCompletion" in { + val completion = ChatCompletionResponse( + id = "test-id", + created = testDate, + model = "test-model", + choices = Seq[ChatCompletionChoiceInfo](), + usage = None + ) + testWrapWith(completion) { + _.createChatCompletion( + Seq(MessageSpec(role = ChatRole.User, "test-prompt")), + DefaultSettings.CreateChatCompletion + ) + } + } + + "call wrap for createEdit" in { + val response = TextEditResponse( + created = testDate, + choices = Seq[TextEditChoiceInfo](), + usage = UsageInfo(0, 0, None) + ) + testWrapWith(response) { + _.createEdit( + "test-input", + "test-instructions", + DefaultSettings.CreateEdit + ) + } + } + + "call wrap for createImage" in { + testWrapWith(imageInfo) { + _.createImage("test-prompt", DefaultSettings.CreateImage) + } + } + + "call wrap for createImageEdit" in { + testWrapWith(imageInfo) { + _.createImageEdit( + "test-prompt", + testFile, + None, + DefaultSettings.CreateImageEdit + ) + } + } + + "call wrap for createImageVariation" in { + testWrapWith(imageInfo) { + _.createImageVariation(testFile, DefaultSettings.CreateImageVariation) + } + } + + "call wrap for createEmbeddings" in { + val response = EmbeddingResponse( + Seq[EmbeddingInfo](), + "test-model", + EmbeddingUsageInfo(0, 0) + ) + testWrapWith(response) { + _.createEmbeddings(Seq[String](), DefaultSettings.CreateEmbeddings) + } + } + + "call wrap for createAudioTranscription" in { + testWrapWith(transcriptResponse) { + _.createAudioTranscription( + testFile, + Some("test-prompt"), + DefaultSettings.CreateTranscription + ) + } + } + + "call wrap for createAudioTranslation" in { + testWrapWith(transcriptResponse) { + _.createAudioTranslation( + testFile, + Some("test-prompt"), + DefaultSettings.CreateTranslation + ) + } + } + + "call wrap for listFiles" in { + val response = Seq[FileInfo]() + testWrapWith(response) { _.listFiles } + } + + "call wrap for uploadFile" in { + val response = FileInfo( + id = "test-id", + bytes = 0, + created_at = testDate, + filename = "test-filename", + purpose = "test-purpose", + status = "test-status", + status_details = None + ) + testWrapWith(response) { + _.uploadFile(testFile, Some("test-name"), DefaultSettings.UploadFile) + } + } + + "call wrap for deleteFile" in { + val response: DeleteResponse = DeleteResponse.Deleted + testWrapWith(response) { + _.deleteFile("test-file-id") + } + } + + "call wrap for retrieveFile" in { + val response: Option[FileInfo] = None + testWrapWith(response) { _.retrieveFile("test-file-id") } + } + + "call wrap for retrieveFileContent" in { + val response: Option[String] = None + testWrapWith(response) { _.retrieveFileContent("test-file-id") } + } + + "call wrap for createFineTune" in { + def testFiles = Seq[FileInfo]() + val response = FineTuneJob( + id = "test-id", + model = "test-model", + created_at = testDate, + events = None, + fine_tuned_model = None, + hyperparams = FineTuneHyperparams(None, None, 0, 0.0), + organization_id = "test-org", + result_files = testFiles, + status = "test-status", + validation_files = testFiles, + training_files = testFiles, + updated_at = testDate + ) + testWrapWith(response) { + _.createFineTune("test-file", None, DefaultSettings.CreateFineTune) + } + } + + "call wrap for listFineTunes" in { + val response = Seq[FineTuneJob]() + testWrapWith(response) { _.listFineTunes } + } + + "call wrap for cancelFineTune" in { + val response: Option[FineTuneJob] = None + testWrapWith(response) { _.retrieveFineTune("test-fine-tune-id") } + } + + "call wrap for listFineTuneEvents" in { + val response: Option[Seq[FineTuneEvent]] = None + testWrapWith(response) { _.listFineTuneEvents("test-fine-tune-id") } + } + + "call wrap for deleteFineTuneModel" in { + val response: DeleteResponse = DeleteResponse.Deleted + testWrapWith(response) { _.deleteFineTuneModel("test-fine-tune-id") } + } + + "call wrap for createModeration" in { + val response = ModerationResponse( + id = "test-id", + model = "test-model", + results = Seq[ModerationResult]() + ) + testWrapWith(response) { + _.createModeration("test-input", DefaultSettings.CreateModeration) + } + } + + } + +} diff --git a/project/plugins.sbt b/project/plugins.sbt index d5445b43..d2658cc8 100755 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,10 @@ logLevel := Level.Warn addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.15") -addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") \ No newline at end of file +addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") + +// Test Coverage plugin. +// ~ +// sbt-scoverage is a plugin for SBT that integrates the scoverage code coverage library. +// See more: https://github.com/scoverage/sbt-scoverage +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")