├── .github ├── FUNDING.yml └── workflows │ ├── KMMBridge-publish.yml │ ├── main.yml │ └── releaase-to-central-sonatype.yml ├── .gitignore ├── LICENSE ├── README.md ├── anthropic-client ├── anthropic-client-core │ ├── build.gradle.kts │ └── src │ │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── tddworks │ │ │ └── anthropic │ │ │ ├── api │ │ │ ├── Anthropic.kt │ │ │ ├── AnthropicConfig.kt │ │ │ ├── AnthropicModel.kt │ │ │ ├── internal │ │ │ │ └── AnthropicApi.kt │ │ │ └── messages │ │ │ │ └── api │ │ │ │ ├── ContentMessage.kt │ │ │ │ ├── ContentSerializer.kt │ │ │ │ ├── CreateMessageRequest.kt │ │ │ │ ├── CreateMessageResponse.kt │ │ │ │ ├── Delta.kt │ │ │ │ ├── Message.kt │ │ │ │ ├── MessageContentSerializer.kt │ │ │ │ ├── Messages.kt │ │ │ │ ├── Role.kt │ │ │ │ ├── StreamMessageResponse.kt │ │ │ │ ├── Usage.kt │ │ │ │ └── internal │ │ │ │ ├── DefaultMessagesApi.kt │ │ │ │ ├── JsonLenient.kt │ │ │ │ └── json │ │ │ │ └── StreamMessageResponseSerializer.kt │ │ │ └── di │ │ │ └── Koin.kt │ │ └── jvmTest │ │ └── kotlin │ │ └── com │ │ └── tddworks │ │ └── anthropic │ │ └── api │ │ ├── AnthropicITest.kt │ │ ├── AnthropicTest.kt │ │ ├── JsonUtils.kt │ │ ├── MockHttpClient.kt │ │ ├── ModelTest.kt │ │ └── messages │ │ └── api │ │ ├── BlockMessageContentTest.kt │ │ ├── ContentMessageTest.kt │ │ ├── ContentTest.kt │ │ ├── CreateMessageRequestTest.kt │ │ ├── CreateMessageResponseTest.kt │ │ ├── DeltaTest.kt │ │ ├── MessageTest.kt │ │ ├── RoleTest.kt │ │ ├── UsageTest.kt │ │ └── internal │ │ ├── DefaultMessagesApiTest.kt │ │ ├── TestKoinCoroutineExtension.kt │ │ └── json │ │ └── StreamMessageResponseTest.kt ├── anthropic-client-darwin │ ├── build.gradle.kts │ └── src │ │ └── appleMain │ │ └── kotlin │ │ └── com │ │ └── tddworks │ │ └── anthropic │ │ └── darwin │ │ └── api │ │ └── DarwinAnthropic.kt ├── build.gradle.kts └── jvm │ └── .gitkeep ├── build.gradle.kts ├── common ├── build.gradle.kts └── src │ ├── appleMain │ └── kotlin │ │ └── com │ │ └── tddworks │ │ └── common │ │ └── network │ │ └── api │ │ └── ktor │ │ └── internal │ │ └── HttpClient.apple.kt │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── tddworks │ │ ├── common │ │ └── network │ │ │ └── api │ │ │ └── ktor │ │ │ ├── api │ │ │ ├── AnySerializer.kt │ │ │ ├── HttpRequester.kt │ │ │ ├── ListResponse.kt │ │ │ └── Stream.kt │ │ │ └── internal │ │ │ ├── AuthConfig.kt │ │ │ ├── ClientFeatures.kt │ │ │ ├── ConnectionConfig.kt │ │ │ ├── DefaultHttpRequester.kt │ │ │ ├── HostPortConnectionConfig.kt │ │ │ ├── HttpClient.kt │ │ │ ├── JsonLenient.kt │ │ │ ├── UrlBasedConnectionConfig.kt │ │ │ └── exception │ │ │ ├── OpenLLMAPIException.kt │ │ │ ├── OpenLLMErrorDetails.kt │ │ │ └── OpenLLMException.kt │ │ └── di │ │ └── Koin.kt │ ├── jvmMain │ └── kotlin │ │ └── com │ │ └── tddworks │ │ └── common │ │ └── network │ │ └── api │ │ └── ktor │ │ └── internal │ │ └── HttpClient.jvm.kt │ └── jvmTest │ └── kotlin │ └── com │ └── tddworks │ ├── common │ └── network │ │ └── api │ │ ├── InternalPackageTest.kt │ │ ├── JsonUtils.kt │ │ ├── MockHttpClient.kt │ │ └── ktor │ │ ├── DefaultHttpRequesterTest.kt │ │ ├── StreamResponse.kt │ │ ├── TestKoinCoroutineExtension.kt │ │ ├── api │ │ └── StreamTest.kt │ │ └── internal │ │ └── HttpClientTest.kt │ └── di │ └── CommonKoinTest.kt ├── gemini-client ├── build.gradle.kts ├── gemini-client-core │ ├── build.gradle.kts │ └── src │ │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── tddworks │ │ │ └── gemini │ │ │ ├── api │ │ │ └── textGeneration │ │ │ │ └── api │ │ │ │ ├── Gemini.kt │ │ │ │ ├── GeminiConfig.kt │ │ │ │ ├── GeminiModel.kt │ │ │ │ ├── GenerateContentRequest.kt │ │ │ │ ├── GenerateContentResponse.kt │ │ │ │ ├── GenerationConfig.kt │ │ │ │ ├── PartSerializer.kt │ │ │ │ ├── TextGeneration.kt │ │ │ │ └── internal │ │ │ │ └── DefaultTextGenerationApi.kt │ │ │ └── di │ │ │ ├── GeminiModule.kt │ │ │ └── Koin.kt │ │ └── jvmTest │ │ └── kotlin │ │ └── com │ │ └── tddworks │ │ └── gemini │ │ └── api │ │ ├── GeminiITest.kt │ │ ├── GeminiTest.kt │ │ └── textGeneration │ │ └── api │ │ ├── GenerateContentRequestTest.kt │ │ ├── GenerateContentResponseTest.kt │ │ ├── GenerationConfigTest.kt │ │ ├── MockHttpClient.kt │ │ └── internal │ │ └── DefaultTextGenerationTest.kt └── gemini-client-darwin │ ├── build.gradle.kts │ └── src │ └── appleMain │ └── kotlin │ └── com │ └── tddworks │ └── gemini │ └── api │ └── DarwinGemini.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── ollama-client ├── api.md ├── build.gradle.kts ├── jvm │ └── .gitkeep ├── ollama-client-core │ ├── build.gradle.kts │ └── src │ │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── tddworks │ │ │ └── ollama │ │ │ ├── api │ │ │ ├── Ollama.kt │ │ │ ├── OllamaConfig.kt │ │ │ ├── OllamaModel.kt │ │ │ ├── chat │ │ │ │ ├── OllamaChat.kt │ │ │ │ ├── OllamaChatRequest.kt │ │ │ │ ├── OllamaChatResponse.kt │ │ │ │ └── internal │ │ │ │ │ └── DefaultOllamaChatApi.kt │ │ │ ├── generate │ │ │ │ ├── OllamaGenerate.kt │ │ │ │ ├── OllamaGenerateRequest.kt │ │ │ │ ├── OllamaGenerateResponse.kt │ │ │ │ └── internal │ │ │ │ │ └── DefaultOllamaGenerateApi.kt │ │ │ ├── internal │ │ │ │ └── OllamaApi.kt │ │ │ └── json │ │ │ │ └── JsonLenient.kt │ │ │ ├── di │ │ │ └── Koin.kt │ │ │ └── implement.md │ │ └── jvmTest │ │ └── kotlin │ │ └── com │ │ └── tddworks │ │ └── ollama │ │ └── api │ │ ├── JsonUtils.kt │ │ ├── MockHttpClient.kt │ │ ├── OllamaConfigTest.kt │ │ ├── OllamaModelTest.kt │ │ ├── OllamaTest.kt │ │ ├── TestKoinCoroutineExtension.kt │ │ ├── chat │ │ ├── OllamaChatRequestTest.kt │ │ ├── OllamaChatResponseTest.kt │ │ └── internal │ │ │ ├── DefaultOllamaChatITest.kt │ │ │ └── DefaultOllamaChatTest.kt │ │ └── generate │ │ ├── OllamaGenerateRequestTest.kt │ │ ├── OllamaGenerateResponseTest.kt │ │ └── internal │ │ └── DefaultOllamaGenerateTest.kt └── ollama-client-darwin │ ├── .gitkeep │ ├── build.gradle.kts │ └── src │ └── appleMain │ └── kotlin │ └── com │ └── tddworks │ └── ollama │ └── api │ └── DarwinOllama.kt ├── openai-client ├── build.gradle.kts ├── jvm │ └── .gitkeep ├── openai-client-cio │ ├── build.gradle.kts │ └── src │ │ └── jvmMain │ │ └── kotlin │ │ └── Stub.kt ├── openai-client-core │ ├── build.gradle.kts │ └── src │ │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── tddworks │ │ │ └── openai │ │ │ ├── api │ │ │ ├── OpenAI.kt │ │ │ ├── OpenAIConfig.kt │ │ │ ├── chat │ │ │ │ ├── api │ │ │ │ │ ├── Chat.kt │ │ │ │ │ ├── ChatCompletion.kt │ │ │ │ │ ├── ChatCompletionChunk.kt │ │ │ │ │ ├── ChatCompletionRequest.kt │ │ │ │ │ ├── ChatMessage.kt │ │ │ │ │ ├── Choice.kt │ │ │ │ │ ├── OpenAIModel.kt │ │ │ │ │ ├── Role.kt │ │ │ │ │ ├── Settings.kt │ │ │ │ │ └── vision │ │ │ │ │ │ ├── VisionMessageContent.kt │ │ │ │ │ │ └── VisionMessageContentSerializer.kt │ │ │ │ └── internal │ │ │ │ │ └── DefaultChatApi.kt │ │ │ ├── images │ │ │ │ ├── api │ │ │ │ │ ├── Image.kt │ │ │ │ │ ├── ImageCreate.kt │ │ │ │ │ ├── Images.kt │ │ │ │ │ ├── Quality.kt │ │ │ │ │ ├── ResponseFormat.kt │ │ │ │ │ ├── Size.kt │ │ │ │ │ └── Style.kt │ │ │ │ └── internal │ │ │ │ │ └── DefaultImagesApi.kt │ │ │ └── legacy │ │ │ │ └── completions │ │ │ │ └── api │ │ │ │ ├── Completion.kt │ │ │ │ ├── CompletionRequest.kt │ │ │ │ ├── Completions.kt │ │ │ │ └── internal │ │ │ │ └── DefaultCompletionsApi.kt │ │ │ └── di │ │ │ └── Koin.kt │ │ ├── jvmMain │ │ └── .gitkeep │ │ └── jvmTest │ │ └── kotlin │ │ └── com │ │ └── tddworks │ │ ├── common │ │ └── network │ │ │ └── api │ │ │ └── ktor │ │ │ ├── api │ │ │ └── AnySerializerTest.kt │ │ │ └── internal │ │ │ ├── HostPortConnectionConfigTest.kt │ │ │ └── exception │ │ │ └── OpenAIErrorTest.kt │ │ └── openai │ │ ├── api │ │ ├── InternalPackageTest.kt │ │ ├── OpenAIITest.kt │ │ ├── OpenAITest.kt │ │ ├── chat │ │ │ ├── api │ │ │ │ ├── ChatChoiceTest.kt │ │ │ │ ├── ChatCompletionChunkTest.kt │ │ │ │ ├── ChatCompletionRequestTest.kt │ │ │ │ ├── ChatCompletionTest.kt │ │ │ │ ├── ChatMessageTest.kt │ │ │ │ ├── ModelTest.kt │ │ │ │ └── vision │ │ │ │ │ └── VisionMessageContentTest.kt │ │ │ └── internal │ │ │ │ └── DefaultChatApiTest.kt │ │ ├── common │ │ │ ├── JsonUtils.kt │ │ │ └── MockHttpClient.kt │ │ ├── images │ │ │ ├── api │ │ │ │ ├── ImageCreateTest.kt │ │ │ │ └── ResponseFormatTest.kt │ │ │ └── internal │ │ │ │ └── DefaultImagesApiTest.kt │ │ └── legacy │ │ │ └── completions │ │ │ └── api │ │ │ ├── CompletionRequestTest.kt │ │ │ ├── CompletionTest.kt │ │ │ └── internal │ │ │ └── DefaultCompletionsTest.kt │ │ └── di │ │ └── OpenAIKoinTest.kt └── openai-client-darwin │ ├── build.gradle.kts │ └── src │ └── appleMain │ └── kotlin │ └── com │ └── tddworks │ └── openai │ └── darwin │ └── api │ └── DarwinOpenAI.kt ├── openai-gateway ├── build.gradle.kts ├── jvm │ └── .gitkeep ├── openai-gateway-core │ ├── build.gradle.kts │ └── src │ │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── tddworks │ │ │ ├── anthropic │ │ │ └── api │ │ │ │ └── messages │ │ │ │ └── api │ │ │ │ ├── Extensions.kt │ │ │ │ └── OpenAIStopReason.kt │ │ │ ├── azure │ │ │ └── api │ │ │ │ ├── AzureAI.kt │ │ │ │ └── internal │ │ │ │ └── AzureChatApi.kt │ │ │ ├── gemini │ │ │ └── api │ │ │ │ └── textGeneration │ │ │ │ └── api │ │ │ │ └── Extensions.kt │ │ │ ├── ollama │ │ │ └── api │ │ │ │ └── chat │ │ │ │ └── api │ │ │ │ └── Extensions.kt │ │ │ └── openai │ │ │ └── gateway │ │ │ ├── api │ │ │ ├── LLMProvider.kt │ │ │ ├── OpenAIGateway.kt │ │ │ ├── OpenAIProvider.kt │ │ │ ├── OpenAIProviderConfig.kt │ │ │ └── internal │ │ │ │ ├── AnthropicOpenAIProvider.kt │ │ │ │ ├── AnthropicOpenAIProviderConfig.kt │ │ │ │ ├── DefaultOpenAIGateway.kt │ │ │ │ ├── DefaultOpenAIProvider.kt │ │ │ │ ├── DefaultOpenAIProviderConfig.kt │ │ │ │ ├── GeminiOpenAIProvider.kt │ │ │ │ ├── GeminiOpenAIProviderConfig.kt │ │ │ │ ├── OllamaOpenAIProvider.kt │ │ │ │ └── OllamaOpenAIProviderConfig.kt │ │ │ └── di │ │ │ └── Koin.kt │ │ ├── jvmMain │ │ └── .gitkeep │ │ ├── jvmTest │ │ └── kotlin │ │ │ └── com │ │ │ └── tddworks │ │ │ ├── anthropic │ │ │ └── api │ │ │ │ ├── AnthropicConfigTest.kt │ │ │ │ ├── AnthropicTest.kt │ │ │ │ └── messages │ │ │ │ └── api │ │ │ │ └── ExtensionsTest.kt │ │ │ ├── azure │ │ │ └── api │ │ │ │ └── AzureAIProviderConfigTest.kt │ │ │ ├── gemini │ │ │ └── api │ │ │ │ └── textGeneration │ │ │ │ └── api │ │ │ │ └── ExtensionsTest.kt │ │ │ ├── ollama │ │ │ └── api │ │ │ │ └── chat │ │ │ │ └── api │ │ │ │ └── ExtensionsTest.kt │ │ │ └── openai │ │ │ └── gateway │ │ │ ├── api │ │ │ ├── OpenAIGatewayITest.kt │ │ │ └── internal │ │ │ │ ├── AnthropicOpenAIProviderConfigTest.kt │ │ │ │ ├── AnthropicOpenAIProviderTest.kt │ │ │ │ ├── DefaultOpenAIGatewayTest.kt │ │ │ │ ├── DefaultOpenAIProviderTest.kt │ │ │ │ ├── GeminiOpenAIProviderTest.kt │ │ │ │ └── OllamaOpenAIProviderTest.kt │ │ │ └── di │ │ │ └── KoinTest.kt │ │ └── macosMain │ │ └── kotlin │ │ └── com │ │ └── tddworks │ │ └── openai │ │ └── gateway │ │ └── di │ │ └── Koin.macos.kt └── openai-gateway-darwin │ ├── build.gradle.kts │ └── src │ └── appleMain │ └── kotlin │ └── com │ └── tddworks │ └── openai │ └── gateway │ └── api │ └── DarwinOpenAIGateway.kt └── settings.gradle.kts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [hanrw] 4 | 5 | -------------------------------------------------------------------------------- /.github/workflows/KMMBridge-publish.yml: -------------------------------------------------------------------------------- 1 | name: Darwin Publish 2 | on: 3 | workflow_dispatch: 4 | # push: 5 | # branches: 6 | # - "main" 7 | jobs: 8 | call-kmmbridge-publish: 9 | permissions: 10 | contents: write 11 | packages: write 12 | uses: touchlab/KMMBridgeGithubWorkflow/.github/workflows/faktorybuildautoversion.yml@v1.1 13 | with: 14 | jvmVersion: 17 15 | versionBaseProperty: LIBRARY_VERSION 16 | # secrets: 17 | # PODSPEC_SSH_KEY: ${{ secrets.PODSPEC_SSH_KEY }} -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Actions - CI 2 | 3 | on: 4 | workflow_dispatch: # Start a workflow 5 | push: 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | build: 11 | runs-on: macOS-latest 12 | env: 13 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }} 14 | SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - uses: actions/cache@v3 20 | with: 21 | path: | 22 | ~/.gradle/caches 23 | ~/.gradle/wrapper 24 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} 25 | restore-keys: | 26 | ${{ runner.os }}-gradle- 27 | - name: Set up JDK 28 | uses: actions/setup-java@v3 29 | with: 30 | java-version: 21 31 | distribution: 'zulu' 32 | - name: Build with Gradle 33 | run: ./gradlew clean build koverVerify koverXmlReport 34 | - name: Upload coverage reports to Codecov 35 | uses: codecov/codecov-action@v4.0.1 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | slug: tddworks/openai-kotlin 39 | files: ${{ github.workspace }}/build/reports/kover/report.xml -------------------------------------------------------------------------------- /.github/workflows/releaase-to-central-sonatype.yml: -------------------------------------------------------------------------------- 1 | name: build and publish a release to Central Sonatype 2 | 3 | #on: 4 | # release: 5 | # types: [published] 6 | 7 | on: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: macOS-latest 13 | env: 14 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 15 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 16 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }} 17 | SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} 18 | steps: 19 | - name: Checkout sources 20 | uses: actions/checkout@v4 21 | - name: Setup Java 22 | uses: actions/setup-java@v4 23 | with: 24 | distribution: zulu 25 | java-version: 17 26 | - name: Setup Gradle 27 | uses: gradle/gradle-build-action@v3 28 | - name: Release to Central Sonatype with Gradle 29 | run: ./gradlew build publishAggregationPublicationsToSonatypePortalRepository -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | .kotlin 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### IntelliJ IDEA ### 9 | .idea 10 | *.iws 11 | *.iml 12 | *.ipr 13 | out/ 14 | !**/src/main/**/out/ 15 | !**/src/test/**/out/ 16 | .run 17 | 18 | ### Eclipse ### 19 | .apt_generated 20 | .classpath 21 | .factorypath 22 | .project 23 | .settings 24 | .springBeans 25 | .sts4-cache 26 | bin/ 27 | !**/src/main/**/bin/ 28 | !**/src/test/**/bin/ 29 | 30 | ### NetBeans ### 31 | /nbproject/private/ 32 | /nbbuild/ 33 | /dist/ 34 | /nbdist/ 35 | /.nb-gradle/ 36 | 37 | ### VS Code ### 38 | .vscode/ 39 | 40 | ### Mac OS ### 41 | .DS_Store 42 | 43 | # Ignore .env file 44 | *.env -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.kover) 4 | `maven-publish` 5 | } 6 | 7 | kotlin { 8 | jvm() 9 | macosArm64() 10 | iosArm64() 11 | iosSimulatorArm64() 12 | 13 | sourceSets { 14 | commonMain.dependencies { 15 | // put your Multiplatform dependencies here 16 | api(projects.common) 17 | } 18 | 19 | commonTest.dependencies { 20 | implementation(libs.ktor.client.mock) 21 | api(projects.common) 22 | } 23 | 24 | macosMain.dependencies { 25 | api(libs.ktor.client.darwin) 26 | } 27 | 28 | jvmMain.dependencies { 29 | api(libs.ktor.client.cio) 30 | } 31 | 32 | jvmTest.dependencies { 33 | implementation(project.dependencies.platform(libs.junit.bom)) 34 | implementation(libs.bundles.jvm.test) 35 | implementation(libs.kotlinx.coroutines.test) 36 | implementation(libs.koin.test) 37 | implementation(libs.koin.test.junit5) 38 | implementation(libs.app.cash.turbine) 39 | implementation("com.tngtech.archunit:archunit-junit5:1.1.0") 40 | implementation("org.reflections:reflections:0.10.2") 41 | implementation(libs.org.skyscreamer.jsonassert) 42 | } 43 | } 44 | } 45 | 46 | tasks { 47 | named("jvmTest") { 48 | useJUnitPlatform() 49 | } 50 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/Anthropic.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | import com.tddworks.anthropic.api.internal.AnthropicApi 4 | import com.tddworks.anthropic.api.messages.api.Messages 5 | import com.tddworks.anthropic.api.messages.api.internal.DefaultMessagesApi 6 | import com.tddworks.anthropic.api.messages.api.internal.JsonLenient 7 | import com.tddworks.common.network.api.ktor.api.HttpRequester 8 | import com.tddworks.common.network.api.ktor.internal.* 9 | 10 | /** 11 | * Interface for interacting with the Anthropic API. 12 | */ 13 | interface Anthropic : Messages { 14 | 15 | /** 16 | * Companion object containing a constant variable for the base URL of the API. 17 | */ 18 | companion object { 19 | const val BASE_URL = "https://api.anthropic.com" 20 | const val ANTHROPIC_VERSION = "2023-06-01" 21 | 22 | /** 23 | * Creates an instance of Anthropic API with the provided configurations. 24 | * @return an instance of Anthropic API configured with the provided settings. 25 | */ 26 | fun create(anthropicConfig: AnthropicConfig): Anthropic { 27 | 28 | val requester = HttpRequester.default( 29 | createHttpClient( 30 | connectionConfig = UrlBasedConnectionConfig(anthropicConfig.baseUrl), 31 | authConfig = AuthConfig(anthropicConfig.apiKey), 32 | // get from commonModule 33 | features = ClientFeatures(json = JsonLenient) 34 | ) 35 | ) 36 | val messages = DefaultMessagesApi( 37 | anthropicConfig = anthropicConfig, 38 | requester = requester 39 | ) 40 | 41 | return AnthropicApi(anthropicConfig = anthropicConfig, messages = messages) 42 | } 43 | } 44 | 45 | /** 46 | * Function to retrieve the API key. 47 | * 48 | * @return a String representing the API key 49 | */ 50 | fun apiKey(): String 51 | 52 | /** 53 | * This function returns the base URL as a string. 54 | * 55 | * @return a string representing the base URL 56 | */ 57 | fun baseUrl(): String 58 | 59 | /** 60 | * Returns the anthropic version of the provided class. 61 | * The anthropic version is a String representation of the class name with "Anthropic" 62 | * prefixed to it. 63 | * 64 | * @return The anthropic version of the class as a String. 65 | */ 66 | fun anthropicVersion(): String 67 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/AnthropicConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | import org.koin.core.component.KoinComponent 4 | 5 | /** 6 | * @param apiKey a function that returns the API key to be used for authentication. Defaults to "CONFIGURE_ME" if not provided. 7 | * @param baseUrl a function that returns the base URL of the API. Defaults to the value specified in the Anthropic companion object if not provided. 8 | * @param anthropicVersion a function that returns the version of the Anthropic API to be used. Defaults to "2023-06-01" if not provided. 9 | */ 10 | data class AnthropicConfig( 11 | val apiKey: () -> String = { "CONFIG_API_KEY" }, 12 | val baseUrl: () -> String = { Anthropic.BASE_URL }, 13 | val anthropicVersion: () -> String = { Anthropic.ANTHROPIC_VERSION }, 14 | ) : KoinComponent 15 | 16 | 17 | -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/AnthropicModel.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | /** 7 | * https://docs.anthropic.com/claude/docs/models-overview 8 | * https://docs.anthropic.com/en/docs/about-claude/models 9 | * Claude is a family of state-of-the-art large language models developed by Anthropic. Our models are designed to provide you with the best possible experience when interacting with AI, offering a range of capabilities and performance levels to suit your needs and make it easy to deploy high performing, safe, and steerable models. In this guide, we'll introduce you to our latest and greatest models, the Claude 3 family, as well as our legacy models, which are still available for those who need them. 10 | */ 11 | @Serializable 12 | @JvmInline 13 | value class AnthropicModel(val value: String) { 14 | companion object { 15 | /** 16 | * Most powerful model for highly complex tasks 17 | * Max output length: 4096 tokens 18 | * Cost (Input / Output per MTok^) $15.00 / $75.00 19 | */ 20 | val CLAUDE_3_OPUS = AnthropicModel("claude-3-opus-20240229") 21 | 22 | /** 23 | * Ideal balance of intelligence and speed for enterprise workloads 24 | * Max output length: 4096 tokens 25 | * Cost (Input / Output per MTok^) $3.00 / $15.00 26 | */ 27 | val CLAUDE_3_Sonnet = AnthropicModel("claude-3-sonnet-20240229") 28 | 29 | /** 30 | * Most intelligent model 31 | * The model costs $3 per million input tokens and $15 per million output tokens, with a 200K token context window. 32 | * Cost (Input / Output per MTok^) $3.00 / $15.00 33 | * Training data cut-off: Apr 2024 34 | */ 35 | val CLAUDE_3_5_Sonnet = AnthropicModel("claude-3-5-sonnet-20240620") 36 | 37 | /** 38 | * Fastest and most compact model for near-instant responsiveness 39 | * Max output length: 4096 tokens 40 | * Cost (Input / Output per MTok^) $0.25 / $1.25 41 | */ 42 | val CLAUDE_3_HAIKU = AnthropicModel("claude-3-haiku-20240307") 43 | 44 | val availableModels = 45 | listOf(CLAUDE_3_OPUS, CLAUDE_3_Sonnet, CLAUDE_3_HAIKU, CLAUDE_3_5_Sonnet) 46 | } 47 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/internal/AnthropicApi.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.internal 2 | 3 | import com.tddworks.anthropic.api.Anthropic 4 | import com.tddworks.anthropic.api.AnthropicConfig 5 | import com.tddworks.anthropic.api.messages.api.Messages 6 | import com.tddworks.di.getInstance 7 | 8 | 9 | /** 10 | * The Anthropic API class encapsulates the necessary properties and methods to interact with the Anthropic API. 11 | * 12 | * @property apiKey the unique identifier for your Anthropic API account 13 | * @property apiURL the base URL for making API requests to the Anthropic API 14 | * @property anthropicVersion the version of the Anthropics library being used 15 | */ 16 | class AnthropicApi( 17 | private val anthropicConfig: AnthropicConfig, 18 | private val messages: Messages = getInstance() 19 | ) : Anthropic, Messages by messages { 20 | /** 21 | * Gets the API key. 22 | * 23 | * @return The API key string. 24 | */ 25 | override fun apiKey(): String { 26 | return anthropicConfig.apiKey() 27 | } 28 | 29 | /** 30 | * Returns the base URL for API requests. 31 | * 32 | * @return The base URL of the API 33 | */ 34 | override fun baseUrl(): String { 35 | return anthropicConfig.baseUrl() 36 | } 37 | 38 | /** 39 | * Returns the anthropic version string. 40 | * 41 | * @return The anthropic version string. 42 | */ 43 | override fun anthropicVersion(): String { 44 | return anthropicConfig.anthropicVersion() 45 | } 46 | 47 | } 48 | 49 | fun Anthropic.Companion.create( 50 | anthropicConfig: AnthropicConfig, messages: Messages = getInstance() 51 | ): Anthropic { 52 | return AnthropicApi( 53 | anthropicConfig = anthropicConfig, messages = messages 54 | ) 55 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/ContentMessage.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ContentMessage(val text: String, val type: String) -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/CreateMessageRequest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import com.tddworks.anthropic.api.AnthropicModel 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class CreateMessageRequest( 9 | val messages: List, 10 | @SerialName("system") 11 | val systemPrompt: String? = null, 12 | @SerialName("max_tokens") 13 | val maxTokens: Int = 1024, 14 | @SerialName("model") 15 | val model: AnthropicModel = AnthropicModel.CLAUDE_3_HAIKU, 16 | val stream: Boolean? = null, 17 | ) { 18 | companion object { 19 | fun streamRequest(messages: List, systemPrompt: String? = null) = 20 | CreateMessageRequest(messages, systemPrompt, stream = true) 21 | } 22 | } 23 | 24 | 25 | -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/CreateMessageResponse.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * { 8 | * "content": [ 9 | * { 10 | * "text": "Hi! My name is Claude.", 11 | * "type": "text" 12 | * } 13 | * ], 14 | * "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF", 15 | * "model": "claude-3-opus-20240229", 16 | * "role": "assistant", 17 | * "stop_reason": "end_turn", 18 | * "stop_sequence": null, 19 | * "type": "message", 20 | * "usage": { 21 | * "input_tokens": 10, 22 | * "output_tokens": 25 23 | * } 24 | * } 25 | */ 26 | @Serializable 27 | data class CreateMessageResponse( 28 | val content: List, 29 | val id: String, 30 | val model: String, 31 | val role: String, 32 | @SerialName("stop_reason") 33 | val stopReason: String?, 34 | @SerialName("stop_sequence") 35 | val stopSequence: String?, 36 | val type: String, 37 | val usage: Usage, 38 | ) { 39 | companion object { 40 | fun dummy() = CreateMessageResponse( 41 | content = listOf( 42 | ContentMessage( 43 | text = "Hi! My name is Claude.", 44 | type = "text" 45 | ) 46 | ), 47 | id = "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", 48 | model = "claude-3-opus-20240229", 49 | role = "assistant", 50 | stopReason = null, 51 | stopSequence = null, 52 | type = "message", 53 | usage = Usage( 54 | inputTokens = 25, 55 | outputTokens = 1 56 | ) 57 | ) 58 | } 59 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/Delta.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * { 8 | * "stop_reason": "end_turn", 9 | * "stop_sequence": null, 10 | * "usage": { 11 | * "output_tokens": 15 12 | * } 13 | * } 14 | * or 15 | * { 16 | * "type": "text_delta", 17 | * "text": "!" 18 | * } 19 | */ 20 | @Serializable 21 | data class Delta( 22 | val type: String? = null, 23 | val text: String? = null, 24 | @SerialName("stop_reason") 25 | val stopReason: String? = null, 26 | @SerialName("stop_sequence") 27 | val stopSequence: String? = null, 28 | val usage: Usage? = null, 29 | ) { 30 | companion object { 31 | fun dummy() = Delta( 32 | type = "text_delta", 33 | text = "Hello", 34 | stopReason = "end_turn", 35 | stopSequence = null, 36 | usage = Usage(outputTokens = 15) 37 | ) 38 | } 39 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/MessageContentSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.SerializationException 5 | import kotlinx.serialization.json.JsonContentPolymorphicSerializer 6 | import kotlinx.serialization.json.JsonElement 7 | import kotlinx.serialization.json.jsonObject 8 | 9 | /** 10 | * { 11 | * "role": "user", 12 | * "content": [ 13 | * { 14 | * "type": "image", 15 | * "source": { 16 | * "type": "base64", 17 | * "media_type": image1_media_type, 18 | * "data": image1_data, 19 | * }, 20 | * }, 21 | * { 22 | * "type": "text", 23 | * "text": "Describe this image." 24 | * } 25 | * ], 26 | * } 27 | */ 28 | //internal object MessageContentSerializer : 29 | // JsonContentPolymorphicSerializer(Content::class) { 30 | // override fun selectDeserializer(element: JsonElement): KSerializer { 31 | // val jsonObject = element.jsonObject 32 | // return when { 33 | // "source" in jsonObject -> BlockMessageContent.ImageContent.serializer() 34 | // "text" in jsonObject -> BlockMessageContent.TextContent.serializer() 35 | // else -> throw SerializationException("Unknown Content type") 36 | // } 37 | // 38 | // } 39 | //} -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/Messages.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | /** 6 | * * Anthropic Messages API -https://docs.anthropic.com/claude/reference/messages_post 7 | */ 8 | interface Messages { 9 | suspend fun create(request: CreateMessageRequest): CreateMessageResponse 10 | 11 | fun stream(request: CreateMessageRequest): Flow 12 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/Role.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | /** 7 | * https://docs.anthropic.com/claude/reference/messages_post 8 | * Our models are trained to operate on alternating user and assistant conversational turns. When creating a new Message, you specify the prior conversational turns with the messages parameter, and the model then generates the next Message in the conversation. 9 | */ 10 | @JvmInline 11 | @Serializable 12 | value class Role(val name: String) { 13 | companion object { 14 | val User: Role = Role("user") 15 | val Assistant: Role = Role("assistant") 16 | } 17 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/StreamMessageResponse.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import com.tddworks.anthropic.api.messages.api.internal.json.StreamMessageResponseSerializer 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | 8 | @Serializable(with = StreamMessageResponseSerializer::class) 9 | sealed interface StreamMessageResponse { 10 | val type: String 11 | } 12 | 13 | @Serializable 14 | data class MessageStart( 15 | override val type: String, 16 | val message: CreateMessageResponse, 17 | ) : StreamMessageResponse 18 | 19 | @Serializable 20 | data class ContentBlockStart( 21 | override val type: String, 22 | val index: Int, 23 | @SerialName("content_block") 24 | val contentBlock: ContentBlock, 25 | ) : StreamMessageResponse 26 | 27 | @Serializable 28 | data class ContentBlock( 29 | val type: String, 30 | val text: String, 31 | ) 32 | 33 | @Serializable 34 | data class ContentBlockDelta( 35 | override val type: String, 36 | val index: Int, 37 | val delta: Delta, 38 | ) : StreamMessageResponse 39 | 40 | @Serializable 41 | data class ContentBlockStop( 42 | override val type: String, 43 | val index: Int, 44 | ) : StreamMessageResponse 45 | 46 | @Serializable 47 | data class MessageDelta( 48 | override val type: String, 49 | val delta: Delta, 50 | ) : StreamMessageResponse 51 | 52 | @Serializable 53 | data class MessageStop( 54 | override val type: String, 55 | ) : StreamMessageResponse 56 | 57 | @Serializable 58 | data class Ping( 59 | override val type: String, 60 | ) : StreamMessageResponse 61 | 62 | 63 | -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/Usage.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class Usage( 8 | @SerialName("input_tokens") 9 | val inputTokens: Int? = null, 10 | @SerialName("output_tokens") 11 | val outputTokens: Int? = null, 12 | ) -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/internal/JsonLenient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api.internal 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | 6 | /** 7 | * Represents a JSON object that allows for leniency and ignores unknown keys. 8 | * 9 | * @property isLenient Removes JSON specification restriction (RFC-4627) and makes parser more liberal to the malformed input. In lenient mode quoted boolean literals, and unquoted string literals are allowed. 10 | * Its relaxations can be expanded in the future, so that lenient parser becomes even more permissive to invalid value in the input, replacing them with defaults. 11 | * false by default. 12 | * @property ignoreUnknownKeys Specifies whether encounters of unknown properties in the input JSON should be ignored instead of throwing SerializationException. false by default.. 13 | */ 14 | val JsonLenient = Json { 15 | isLenient = true 16 | ignoreUnknownKeys = true 17 | encodeDefaults = true 18 | explicitNulls = false 19 | } 20 | -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/internal/json/StreamMessageResponseSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api.internal.json 2 | 3 | import com.tddworks.anthropic.api.messages.api.* 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.json.JsonContentPolymorphicSerializer 6 | import kotlinx.serialization.json.JsonElement 7 | import kotlinx.serialization.json.jsonObject 8 | import kotlinx.serialization.json.jsonPrimitive 9 | 10 | object StreamMessageResponseSerializer : 11 | JsonContentPolymorphicSerializer(StreamMessageResponse::class) { 12 | override fun selectDeserializer(element: JsonElement): KSerializer { 13 | val jsonElement = element.jsonObject["type"] 14 | 15 | val jsonPrimitive = jsonElement?.jsonPrimitive 16 | 17 | val type = jsonPrimitive?.content 18 | 19 | return when (type) { 20 | "message_start" -> MessageStart.serializer() 21 | "content_block_start" -> ContentBlockStart.serializer() 22 | "content_block_delta" -> ContentBlockDelta.serializer() 23 | "content_block_stop" -> ContentBlockStop.serializer() 24 | "message_delta" -> MessageDelta.serializer() 25 | "message_stop" -> MessageStop.serializer() 26 | "ping" -> Ping.serializer() 27 | else -> throw IllegalArgumentException("Unknown type of message") 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/di/Koin.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.di 2 | 3 | import com.tddworks.anthropic.api.Anthropic 4 | import com.tddworks.anthropic.api.AnthropicConfig 5 | import com.tddworks.anthropic.api.messages.api.Messages 6 | import com.tddworks.anthropic.api.messages.api.internal.DefaultMessagesApi 7 | import com.tddworks.anthropic.api.messages.api.internal.JsonLenient 8 | import com.tddworks.common.network.api.ktor.api.HttpRequester 9 | import com.tddworks.common.network.api.ktor.internal.* 10 | import com.tddworks.di.commonModule 11 | import kotlinx.serialization.json.Json 12 | import org.koin.core.context.startKoin 13 | import org.koin.core.qualifier.named 14 | import org.koin.dsl.KoinAppDeclaration 15 | import org.koin.dsl.module 16 | 17 | fun iniAnthropic( 18 | config: AnthropicConfig, 19 | appDeclaration: KoinAppDeclaration = {} 20 | ): Anthropic { 21 | return startKoin { 22 | appDeclaration() 23 | modules(commonModule(false) + anthropicModules(config)) 24 | }.koin.get() 25 | } 26 | 27 | fun anthropicModules( 28 | config: AnthropicConfig, 29 | ) = module { 30 | single { 31 | Anthropic.create(anthropicConfig = config) 32 | } 33 | 34 | single(named("anthropicJson")) { JsonLenient } 35 | 36 | single(named("anthropicHttpRequester")) { 37 | HttpRequester.default( 38 | createHttpClient( 39 | connectionConfig = UrlBasedConnectionConfig(config.baseUrl), 40 | authConfig = AuthConfig(config.apiKey), 41 | // get from commonModule 42 | features = ClientFeatures(json = get(named("anthropicJson"))) 43 | ) 44 | ) 45 | } 46 | 47 | single { 48 | DefaultMessagesApi( 49 | anthropicConfig = config, 50 | requester = get(named("anthropicHttpRequester")) 51 | ) 52 | } 53 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/AnthropicTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | import org.junit.jupiter.api.Assertions.assertEquals 4 | import org.junit.jupiter.api.Test 5 | 6 | class AnthropicTest { 7 | 8 | val anthropic: Anthropic = Anthropic.create(AnthropicConfig()) 9 | 10 | @Test 11 | fun `should return default settings`() { 12 | assertEquals("https://api.anthropic.com", anthropic.baseUrl()) 13 | 14 | assertEquals("CONFIG_API_KEY", anthropic.apiKey()) 15 | 16 | assertEquals("2023-06-01", anthropic.anthropicVersion()) 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/JsonUtils.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | import kotlinx.serialization.EncodeDefault 4 | import kotlinx.serialization.json.Json 5 | 6 | val prettyJson = Json { // this returns the JsonBuilder 7 | prettyPrint = true 8 | ignoreUnknownKeys = true 9 | // optional: specify indent 10 | prettyPrintIndent = " " 11 | 12 | /** 13 | * Controls whether the target property is serialized when its value is equal to a default value, 14 | * regardless of the format settings. 15 | * Does not affect decoding and deserialization process. 16 | * 17 | * Example of usage: 18 | * ``` 19 | * @Serializable 20 | * data class Foo( 21 | * @EncodeDefault(ALWAYS) val a: Int = 42, 22 | * @EncodeDefault(NEVER) val b: Int = 43, 23 | * val c: Int = 44 24 | * ) 25 | * 26 | * Json { encodeDefaults = false }.encodeToString((Foo()) // {"a": 42} 27 | * Json { encodeDefaults = true }.encodeToString((Foo()) // {"a": 42, "c":44} 28 | * ``` 29 | * 30 | * @see EncodeDefault.Mode.ALWAYS 31 | * @see EncodeDefault.Mode.NEVER 32 | */ 33 | encodeDefaults = true 34 | } 35 | 36 | /** 37 | * Represents a JSON object that allows for leniency and ignores unknown keys. 38 | * 39 | * @property isLenient Removes JSON specification restriction (RFC-4627) and makes parser more liberal to the malformed input. In lenient mode quoted boolean literals, and unquoted string literals are allowed. 40 | * Its relaxations can be expanded in the future, so that lenient parser becomes even more permissive to invalid value in the input, replacing them with defaults. 41 | * false by default. 42 | * @property ignoreUnknownKeys Specifies whether encounters of unknown properties in the input JSON should be ignored instead of throwing SerializationException. false by default.. 43 | */ 44 | internal val JsonLenient = Json { 45 | isLenient = true 46 | ignoreUnknownKeys = true 47 | // https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md#class-discriminator-for-polymorphism 48 | encodeDefaults = true 49 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/MockHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | 4 | import io.ktor.client.* 5 | import io.ktor.client.engine.mock.* 6 | import io.ktor.client.plugins.* 7 | import io.ktor.client.plugins.contentnegotiation.* 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | import io.ktor.serialization.kotlinx.* 11 | 12 | /** 13 | * See https://ktor.io/docs/http-client-testing.html#usage 14 | */ 15 | fun mockHttpClient(mockResponse: String) = HttpClient(MockEngine) { 16 | 17 | val headers = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) 18 | 19 | install(ContentNegotiation) { 20 | register(ContentType.Application.Json, KotlinxSerializationConverter(JsonLenient)) 21 | } 22 | 23 | engine { 24 | addHandler { request -> 25 | if (request.url.encodedPath == "/v1/messages") { 26 | respond(mockResponse, HttpStatusCode.OK, headers) 27 | } else { 28 | error("Unhandled ${request.url.encodedPath}") 29 | } 30 | } 31 | } 32 | 33 | defaultRequest { 34 | url { 35 | protocol = URLProtocol.HTTPS 36 | host = "api.lemonsqueezy.com" 37 | } 38 | 39 | header(HttpHeaders.ContentType, ContentType.Application.Json) 40 | contentType(ContentType.Application.Json) 41 | } 42 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/ModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | class ModelTest { 7 | 8 | @Test 9 | fun `should return correct latest API model name`() { 10 | assertEquals("claude-3-opus-20240229", AnthropicModel.CLAUDE_3_OPUS.value) 11 | assertEquals("claude-3-sonnet-20240229", AnthropicModel.CLAUDE_3_Sonnet.value) 12 | assertEquals("claude-3-haiku-20240307", AnthropicModel.CLAUDE_3_HAIKU.value) 13 | assertEquals("claude-3-5-sonnet-20240620", AnthropicModel.CLAUDE_3_5_Sonnet.value) 14 | } 15 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/BlockMessageContentTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.serialization.json.Json 4 | import org.junit.jupiter.api.Test 5 | import org.skyscreamer.jsonassert.JSONAssert 6 | 7 | class BlockMessageContentTest { 8 | 9 | @Test 10 | fun `should serialize image message content`() { 11 | // Given 12 | val messageContent = BlockMessageContent.ImageContent( 13 | source = BlockMessageContent.ImageContent.Source( 14 | mediaType = "image1_media_type", 15 | data = "image1_data", 16 | type = "base64", 17 | ), 18 | ) 19 | 20 | // When 21 | val result = Json.encodeToString( 22 | BlockMessageContent.serializer(), 23 | messageContent 24 | ) 25 | 26 | // Then 27 | JSONAssert.assertEquals( 28 | """ 29 | { 30 | "type": "image", 31 | "source": { 32 | "type": "base64", 33 | "media_type": "image1_media_type", 34 | "data": "image1_data" 35 | } 36 | } 37 | """.trimIndent(), 38 | result, 39 | false 40 | ) 41 | } 42 | 43 | @Test 44 | fun `should serialize message content`() { 45 | // Given 46 | val messageContent = BlockMessageContent.TextContent( 47 | text = "some-text", 48 | ) 49 | 50 | // When 51 | val result = Json.encodeToString( 52 | BlockMessageContent.serializer(), 53 | messageContent 54 | ) 55 | 56 | // Then 57 | JSONAssert.assertEquals( 58 | """ 59 | { 60 | "text": "some-text", 61 | "type": "text" 62 | } 63 | """.trimIndent(), 64 | result, 65 | false 66 | ) 67 | } 68 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/ContentMessageTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | class ContentMessageTest { 7 | 8 | @Test 9 | fun `should create a ContentMessage`() { 10 | val dummyMessage = ContentMessage("Hi! My name is Claude.", "text") 11 | 12 | assertEquals("Hi! My name is Claude.", dummyMessage.text) 13 | assertEquals("text", dummyMessage.type) 14 | } 15 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/CreateMessageResponseTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | 7 | class CreateMessageResponseTest { 8 | 9 | @Test 10 | fun `should create a dummy CreateMessageResponse`() { 11 | 12 | val dummyResponse = CreateMessageResponse.dummy() 13 | 14 | assertEquals("msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", dummyResponse.id) 15 | assertEquals("claude-3-opus-20240229", dummyResponse.model) 16 | assertEquals("assistant", dummyResponse.role) 17 | assertNull(dummyResponse.stopReason) 18 | assertNull(dummyResponse.stopSequence) 19 | assertEquals("message", dummyResponse.type) 20 | assertEquals(Usage(25, 1), dummyResponse.usage) 21 | assertEquals( 22 | listOf(ContentMessage("Hi! My name is Claude.", "text")), 23 | dummyResponse.content 24 | ) 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/DeltaTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | 7 | class DeltaTest { 8 | 9 | @Test 10 | fun `should create dummy Delta`() { 11 | val delta = Delta.dummy() 12 | assertEquals("text_delta", delta.type) 13 | assertEquals("Hello", delta.text) 14 | assertEquals("end_turn", delta.stopReason) 15 | assertEquals(null, delta.stopSequence) 16 | assertEquals(Usage(outputTokens = 15), delta.usage) 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/RoleTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | 7 | class RoleTest { 8 | 9 | @Test 10 | fun `should return correct role name`() { 11 | assertEquals("user", Role.User.name) 12 | assertEquals("assistant", Role.Assistant.name) 13 | } 14 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/UsageTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | import kotlinx.serialization.encodeToString 4 | import kotlinx.serialization.json.Json 5 | import org.junit.jupiter.api.Assertions.assertEquals 6 | import org.junit.jupiter.api.Test 7 | 8 | class UsageTest { 9 | @Test 10 | fun `should create a empty Usage`() { 11 | val usage = Usage() 12 | val json = Json.encodeToString(usage) 13 | 14 | val expectedJson = "{}" 15 | assertEquals(expectedJson, json) 16 | } 17 | 18 | @Test 19 | fun `should create a dummy Usage`() { 20 | val usage = Usage(inputTokens = 10, outputTokens = 20) 21 | val json = Json.encodeToString(usage) 22 | 23 | val expectedJson = "{\"input_tokens\":10,\"output_tokens\":20}" 24 | assertEquals(expectedJson, json) 25 | } 26 | 27 | @Test 28 | fun `should parse a Usage from json`() { 29 | val json = "{\"input_tokens\":5,\"output_tokens\":15}" 30 | val usage = Json.decodeFromString(json) 31 | 32 | assertEquals(5, usage.inputTokens) 33 | assertEquals(15, usage.outputTokens) 34 | } 35 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/internal/TestKoinCoroutineExtension.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api.internal 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.test.StandardTestDispatcher 5 | import kotlinx.coroutines.test.TestDispatcher 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.setMain 8 | import org.junit.jupiter.api.extension.AfterEachCallback 9 | import org.junit.jupiter.api.extension.BeforeEachCallback 10 | import org.junit.jupiter.api.extension.ExtensionContext 11 | 12 | class TestKoinCoroutineExtension(private val testDispatcher: TestDispatcher = StandardTestDispatcher()) : 13 | BeforeEachCallback, AfterEachCallback { 14 | override fun beforeEach(context: ExtensionContext?) { 15 | Dispatchers.setMain(testDispatcher) 16 | } 17 | 18 | override fun afterEach(context: ExtensionContext?) { 19 | Dispatchers.resetMain() 20 | } 21 | } -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-darwin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.touchlab.kmmbridge) 4 | alias(libs.plugins.touchlab.skie) 5 | `maven-publish` 6 | } 7 | 8 | kotlin { 9 | listOf( 10 | macosArm64(), 11 | iosArm64(), 12 | iosSimulatorArm64() 13 | ).forEach { macosTarget -> 14 | macosTarget.binaries.framework { 15 | baseName = "AnthropicClient" 16 | export(projects.anthropicClient.anthropicClientCore) 17 | isStatic = true 18 | } 19 | } 20 | 21 | sourceSets { 22 | commonMain { 23 | dependencies { 24 | api(projects.anthropicClient.anthropicClientCore) 25 | implementation(libs.ktor.client.darwin) 26 | } 27 | } 28 | appleMain {} 29 | } 30 | } 31 | 32 | addGithubPackagesRepository() // <- Add the GitHub Packages repo 33 | 34 | kmmbridge { 35 | /** 36 | * reference: https://kmmbridge.touchlab.co/docs/artifacts/MAVEN_REPO_ARTIFACTS#github-packages 37 | * In kmmbridge, notice mavenPublishArtifacts() tells the plugin to push KMMBridge artifacts to a Maven repo. You then need to define a repo. Rather than do everything manually, you can just call addGithubPackagesRepository(), which will add the correct repo given parameters that are passed in from GitHub Actions. 38 | */ 39 | mavenPublishArtifacts() // <- Publish using a Maven repo 40 | /** 41 | * https://github.com/touchlab/KMMBridge/issues/258 42 | */ 43 | spm( 44 | swiftToolVersion = "5.9", 45 | useCustomPackageFile = true, 46 | perModuleVariablesBlock = true 47 | ) { 48 | iOS { v("18") } 49 | macOS { v("15") } 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /anthropic-client/anthropic-client-darwin/src/appleMain/kotlin/com/tddworks/anthropic/darwin/api/DarwinAnthropic.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.darwin.api 2 | 3 | import com.tddworks.anthropic.api.Anthropic 4 | import com.tddworks.anthropic.api.AnthropicConfig 5 | import com.tddworks.anthropic.di.iniAnthropic 6 | 7 | 8 | /** 9 | * Object responsible for setting up and initializing the Anthropoc API client. 10 | */ 11 | object DarwinAnthropic { 12 | 13 | /** 14 | * Initializes the Anthropic library with the provided configuration parameters. 15 | * 16 | * @param apiKey a lambda function that returns the API key to be used for authentication 17 | * @param baseUrl a lambda function that returns the base URL of the Anthropic API 18 | * @param anthropicVersion a lambda function that returns the version of the Anthropic API to use 19 | */ 20 | fun anthropic( 21 | apiKey: () -> String = { "CONFIG_API_KEY" }, 22 | baseUrl: () -> String = { Anthropic.BASE_URL }, 23 | anthropicVersion: () -> String = { Anthropic.ANTHROPIC_VERSION }, 24 | ) = iniAnthropic(AnthropicConfig(apiKey, baseUrl, anthropicVersion)) 25 | } 26 | -------------------------------------------------------------------------------- /anthropic-client/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `maven-publish` 3 | } 4 | 5 | kotlin { 6 | jvm() 7 | sourceSets { 8 | commonMain { 9 | dependencies { 10 | api(projects.anthropicClient.anthropicClientCore) 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /anthropic-client/jvm/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tddworks/openai-kotlin/02e262493ef8b80ee89dd6ca8e705efd136fef13/anthropic-client/jvm/.gitkeep -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `maven-publish` 3 | //trick: for the same plugin versions in all sub-modules 4 | alias(libs.plugins.androidLibrary).apply(false) 5 | alias(libs.plugins.kotlinMultiplatform).apply(false) 6 | alias(libs.plugins.kover) 7 | alias(libs.plugins.com.linecorp.build.recipe) 8 | alias(libs.plugins.build.dokka.plugin) 9 | 10 | alias(libs.plugins.kotlinx.binary.validator) apply false 11 | id("com.tddworks.central-portal-publisher") version "0.0.5" 12 | } 13 | 14 | sonatypePortalPublisher { 15 | settings { 16 | autoPublish = false 17 | aggregation = true 18 | } 19 | } 20 | 21 | 22 | dependencies { 23 | kover(projects.openaiClient.openaiClientCore) 24 | kover(projects.anthropicClient.anthropicClientCore) 25 | kover(projects.openaiGateway.openaiGatewayCore) 26 | kover(projects.ollamaClient.ollamaClientCore) 27 | kover(projects.geminiClient.geminiClientCore) 28 | kover(projects.common) 29 | } 30 | 31 | val autoVersion = project.property( 32 | if (project.hasProperty("AUTO_VERSION")) { 33 | "AUTO_VERSION" 34 | } else { 35 | "LIBRARY_VERSION" 36 | } 37 | ) as String 38 | 39 | subprojects { 40 | /** 41 | * Working with runtime properties 42 | * https://docs.gradle.org/current/userguide/kotlin_dsl.html 43 | */ 44 | val GROUP: String by project 45 | group = GROUP 46 | version = autoVersion 47 | apply(plugin = rootProject.libs.plugins.kotlinMultiplatform.get().pluginId) 48 | apply(plugin = rootProject.libs.plugins.build.dokka.plugin.get().pluginId) 49 | } 50 | 51 | kover { 52 | reports { 53 | filters { 54 | excludes { 55 | classes( 56 | "com.tddworks.**.*\$*$*", // Lambda functions like - LemonSqueezyLicenseApi$activeLicense$activationResult$1 57 | "com.tddworks.**.*\$Companion", // Lambda functions like - LemonSqueezyLicenseApi$activeLicense$activationResult$1 58 | "*.*\$\$serializer", // Kotlinx serializer) 59 | ) 60 | // inheritedFrom("org.koin.core.component.KoinComponent") 61 | // annotatedBy("kotlinx.serialization.Serializable") 62 | } 63 | includes { 64 | classes("com.tddworks.*") 65 | } 66 | } 67 | 68 | verify { 69 | rule { 70 | bound { 71 | minValue = 86 72 | } 73 | } 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.kover) 4 | `maven-publish` 5 | } 6 | 7 | kotlin { 8 | jvm() 9 | macosArm64() 10 | iosArm64() 11 | iosSimulatorArm64() 12 | 13 | sourceSets { 14 | commonMain.dependencies { 15 | // put your Multiplatform dependencies here 16 | api(libs.kotlinx.coroutines.core) 17 | api(libs.kotlinx.serialization.json) 18 | api(libs.bundles.ktor.client) 19 | // di 20 | api(libs.koin.core) 21 | api(libs.koin.annotations) 22 | } 23 | 24 | commonTest.dependencies { 25 | implementation(libs.ktor.client.mock) 26 | } 27 | 28 | appleMain.dependencies { 29 | api(libs.ktor.client.darwin) 30 | } 31 | 32 | jvmMain.dependencies { 33 | api(libs.ktor.client.okhttp) 34 | } 35 | 36 | jvmTest.dependencies { 37 | implementation(project.dependencies.platform(libs.junit.bom)) 38 | implementation(libs.bundles.jvm.test) 39 | implementation(libs.kotlinx.coroutines.test) 40 | implementation(libs.koin.test) 41 | implementation(libs.koin.test.junit5) 42 | implementation(libs.app.cash.turbine) 43 | implementation("com.tngtech.archunit:archunit-junit5:1.1.0") 44 | implementation("org.reflections:reflections:0.10.2") 45 | } 46 | } 47 | } 48 | 49 | tasks { 50 | named("jvmTest") { 51 | useJUnitPlatform() 52 | } 53 | } -------------------------------------------------------------------------------- /common/src/appleMain/kotlin/com/tddworks/common/network/api/ktor/internal/HttpClient.apple.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | import io.ktor.client.engine.* 4 | import io.ktor.client.engine.darwin.* 5 | 6 | internal actual fun httpClientEngine(): HttpClientEngine { 7 | return Darwin.create { 8 | this.configureSession { 9 | connectionProxyDictionary = emptyMap() 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/api/HttpRequester.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.api 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.client.statement.* 5 | import io.ktor.util.reflect.* 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.flow 8 | 9 | /** 10 | * Interface for performing HTTP requests. 11 | */ 12 | interface HttpRequester { 13 | suspend fun performRequest(info: TypeInfo, builder: HttpRequestBuilder.() -> Unit): T 14 | 15 | /** 16 | * Perform an HTTP request and get a result. 17 | * 18 | * Note: [HttpResponse] instance shouldn't be passed outside of [block]. 19 | */ 20 | suspend fun streamRequest( 21 | builder: HttpRequestBuilder.() -> Unit, 22 | block: suspend (response: HttpResponse) -> T, 23 | ) 24 | 25 | companion object 26 | } 27 | 28 | /** 29 | * Perform an HTTP request and retrieve a result. 30 | * @param builder The HttpRequestBuilder that contains the HTTP request details. 31 | * @return The result of the HTTP request. 32 | */ 33 | suspend inline fun HttpRequester.performRequest(noinline builder: HttpRequestBuilder.() -> Unit): T { 34 | return performRequest(typeInfo(), builder) 35 | } 36 | 37 | 38 | /** 39 | * Perform an HTTP request and get a result 40 | */ 41 | inline fun HttpRequester.streamRequest( 42 | noinline builder: HttpRequestBuilder.() -> Unit, 43 | ): Flow { 44 | return flow { 45 | streamRequest(builder) { response -> 46 | streamEventsFrom(response) 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/api/ListResponse.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.api 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ListResponse( 7 | val created: Long, 8 | val data: List, 9 | ) -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/api/Stream.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.api 2 | 3 | import com.tddworks.di.getInstance 4 | import io.ktor.client.call.* 5 | import io.ktor.client.statement.* 6 | import io.ktor.utils.io.* 7 | import kotlinx.coroutines.flow.FlowCollector 8 | import kotlinx.serialization.json.Json 9 | 10 | const val STREAM_PREFIX = "data:" 11 | private const val STREAM_END_TOKEN = "$STREAM_PREFIX [DONE]" 12 | 13 | /** 14 | * Get data as [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format). 15 | */ 16 | suspend inline fun FlowCollector.streamEventsFrom(response: HttpResponse) { 17 | val channel: ByteReadChannel = response.body() 18 | val json = json() 19 | while (!channel.isClosedForRead) { 20 | val line = channel.readUTF8Line() ?: continue 21 | val value: T = when { 22 | endStreamResponse(line) -> break 23 | isStreamResponse(line) -> json.decodeFromString(line.removePrefix(STREAM_PREFIX)) // If the response indicates streaming data, decode and emit it. 24 | isJsonResponse(line) -> json.decodeFromString(line) // Ollama - response is a json object without `data:` prefix 25 | else -> continue 26 | } 27 | emit(value) 28 | } 29 | } 30 | 31 | 32 | fun json(): Json { 33 | return getInstance() 34 | } 35 | 36 | 37 | fun isStreamResponse(line: String) = line.startsWith(STREAM_PREFIX) 38 | 39 | fun endStreamResponse(line: String) = line.startsWith(STREAM_END_TOKEN) 40 | 41 | fun isJsonResponse(line: String) = line.startsWith("{") && line.endsWith("}") -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/AuthConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | data class AuthConfig( 4 | val authToken: (() -> String)? = null 5 | ) -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/ClientFeatures.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | data class ClientFeatures( 6 | val json: Json = Json, 7 | val queryParams: () -> Map = { emptyMap() }, 8 | val expectSuccess: Boolean = true 9 | ) -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/ConnectionConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | import io.ktor.client.plugins.* 4 | import io.ktor.http.* 5 | 6 | interface ConnectionConfig { 7 | fun setupUrl(builder: DefaultRequest.DefaultRequestBuilder) { 8 | builder.setupUrl(this) 9 | } 10 | } 11 | 12 | private fun DefaultRequest.DefaultRequestBuilder.setupUrl(connectionConfig: ConnectionConfig) { 13 | when (connectionConfig) { 14 | is HostPortConnectionConfig -> setupHostPortConnectionConfig(connectionConfig) 15 | is UrlBasedConnectionConfig -> url.takeFrom(connectionConfig.baseUrl()) 16 | } 17 | } 18 | 19 | private fun DefaultRequest.DefaultRequestBuilder.setupHostPortConnectionConfig(config: HostPortConnectionConfig) { 20 | url { 21 | protocol = config.protocol()?.let { URLProtocol.createOrDefault(it) } ?: URLProtocol.HTTPS 22 | host = config.host() 23 | config.port()?.let { port = it } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/HostPortConnectionConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | data class HostPortConnectionConfig( 4 | val protocol: () -> String? = { null }, 5 | val port: () -> Int? = { null }, 6 | val host: () -> String, 7 | ) : ConnectionConfig -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.engine.* 5 | import io.ktor.client.plugins.* 6 | import io.ktor.client.plugins.contentnegotiation.* 7 | import io.ktor.client.plugins.logging.* 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | import io.ktor.serialization.kotlinx.* 11 | import io.ktor.util.* 12 | 13 | 14 | internal expect fun httpClientEngine(): HttpClientEngine 15 | 16 | 17 | fun createHttpClient( 18 | connectionConfig: ConnectionConfig = UrlBasedConnectionConfig(), 19 | authConfig: AuthConfig = AuthConfig(), 20 | features: ClientFeatures = ClientFeatures(), 21 | httpClientEngine: HttpClientEngine = httpClientEngine() 22 | ): HttpClient { 23 | 24 | return HttpClient(httpClientEngine) { 25 | install(ContentNegotiation) { 26 | register( 27 | ContentType.Application.Json, 28 | KotlinxSerializationConverter(features.json) 29 | ) 30 | } 31 | 32 | install(Logging) { 33 | logger = Logger.DEFAULT 34 | level = LogLevel.INFO 35 | } 36 | 37 | install(HttpRequestRetry) { 38 | maxRetries = 3 39 | retryIf { _, response -> response.status.value == 429 } 40 | exponentialDelay(base = 5.0, maxDelayMs = 60_000) 41 | } 42 | 43 | 44 | defaultRequest { 45 | connectionConfig.setupUrl(this) 46 | commonSettings(features.queryParams, authConfig.authToken) 47 | } 48 | 49 | expectSuccess = features.expectSuccess 50 | } 51 | } 52 | 53 | private fun DefaultRequest.DefaultRequestBuilder.commonSettings( 54 | queryParams: (() -> Map), 55 | authToken: (() -> String)? 56 | ) { 57 | queryParams().forEach { (key, value) -> 58 | url.parameters.appendIfNameAbsent( 59 | key, 60 | value 61 | ) 62 | } 63 | 64 | authToken?.let { 65 | header(HttpHeaders.Authorization, "Bearer ${it()}") 66 | } 67 | 68 | header(HttpHeaders.ContentType, ContentType.Application.Json) 69 | contentType(ContentType.Application.Json) 70 | } 71 | 72 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/JsonLenient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | import kotlinx.serialization.EncodeDefault 4 | import kotlinx.serialization.json.Json 5 | 6 | 7 | /** 8 | * Represents a JSON object that allows for leniency and ignores unknown keys. 9 | * 10 | * @property isLenient Removes JSON specification restriction (RFC-4627) and makes parser more liberal to the malformed input. In lenient mode quoted boolean literals, and unquoted string literals are allowed. 11 | * Its relaxations can be expanded in the future, so that lenient parser becomes even more permissive to invalid value in the input, replacing them with defaults. 12 | * false by default. 13 | * @property ignoreUnknownKeys Specifies whether encounters of unknown properties in the input JSON should be ignored instead of throwing SerializationException. false by default.. 14 | */ 15 | val JsonLenient = Json { 16 | isLenient = true 17 | ignoreUnknownKeys = true 18 | /** 19 | * When this flag is disabled properties with null values without default are not encoded. 20 | */ 21 | explicitNulls = false 22 | /** 23 | * Controls whether the target property is serialized when its value is equal to a default value, 24 | * regardless of the format settings. 25 | * Does not affect decoding and deserialization process. 26 | * 27 | * Example of usage: 28 | * ``` 29 | * @Serializable 30 | * data class Foo( 31 | * @EncodeDefault(ALWAYS) val a: Int = 42, 32 | * @EncodeDefault(NEVER) val b: Int = 43, 33 | * val c: Int = 44 34 | * ) 35 | * 36 | * Json { encodeDefaults = false }.encodeToString((Foo()) // {"a": 42} 37 | * Json { encodeDefaults = true }.encodeToString((Foo()) // {"a": 42, "c":44} 38 | * ``` 39 | * 40 | * @see EncodeDefault.Mode.ALWAYS 41 | * @see EncodeDefault.Mode.NEVER 42 | */ 43 | encodeDefaults = true 44 | } -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/UrlBasedConnectionConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | data class UrlBasedConnectionConfig( 4 | val baseUrl: () -> String = { "" } 5 | ) : ConnectionConfig -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/exception/OpenLLMAPIException.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal.exception 2 | 3 | 4 | /** 5 | * Represents an exception thrown when an error occurs while interacting with the OpenAI API. 6 | * 7 | * @property statusCode the HTTP status code associated with the error. 8 | * @property error an instance of [OpenAIError] containing information about the error that occurred. 9 | */ 10 | sealed class OpenAIAPIException( 11 | private val statusCode: Int, 12 | private val error: OpenAIError, 13 | throwable: Throwable? = null, 14 | ) : OpenAIException(message = error.detail?.message, throwable = throwable) 15 | 16 | /** 17 | * Represents an exception thrown when the OpenAI API rate limit is exceeded. 18 | */ 19 | class RateLimitException( 20 | statusCode: Int, 21 | error: OpenAIError, 22 | throwable: Throwable? = null, 23 | ) : OpenAIAPIException(statusCode, error, throwable) 24 | 25 | /** 26 | * Represents an exception thrown when an invalid request is made to the OpenAI API. 27 | */ 28 | class InvalidRequestException( 29 | statusCode: Int, 30 | error: OpenAIError, 31 | throwable: Throwable? = null, 32 | ) : OpenAIAPIException(statusCode, error, throwable) 33 | 34 | /** 35 | * Represents an exception thrown when an authentication error occurs while interacting with the OpenAI API. 36 | */ 37 | class AuthenticationException( 38 | statusCode: Int, 39 | error: OpenAIError, 40 | throwable: Throwable? = null, 41 | ) : OpenAIAPIException(statusCode, error, throwable) 42 | 43 | /** 44 | * Represents an exception thrown when a permission error occurs while interacting with the OpenAI API. 45 | */ 46 | class PermissionException( 47 | statusCode: Int, 48 | error: OpenAIError, 49 | throwable: Throwable? = null, 50 | ) : OpenAIAPIException(statusCode, error, throwable) 51 | 52 | /** 53 | * Represents an exception thrown when an unknown error occurs while interacting with the OpenAI API. 54 | * This exception is used when the specific type of error is not covered by the existing subclasses. 55 | */ 56 | class UnknownAPIException( 57 | statusCode: Int, 58 | error: OpenAIError, 59 | throwable: Throwable? = null, 60 | ) : OpenAIAPIException(statusCode, error, throwable) 61 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/exception/OpenLLMErrorDetails.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal.exception 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Represents an error response from the OpenAI API. 8 | * 9 | * @param detail information about the error that occurred. 10 | */ 11 | @Serializable 12 | data class OpenAIError( 13 | @SerialName("error") val detail: OpenAIErrorDetails?, 14 | ) 15 | 16 | /** 17 | * Represents an error object returned by the OpenAI API. 18 | * { 19 | * "code": null, 20 | * "type": "server_error", 21 | * "param": null, 22 | * "message": "That model is currently overloaded with other requests. You can retry your request, or contact us through our help center at help.openai.com if the error persists. (Please include the request ID c58c33110e4907638de58bec34af86e5 in your message.)" 23 | * } 24 | * @param code error code returned by the OpenAI API. 25 | * @param message human-readable error message describing the error that occurred. 26 | * @param param the parameter that caused the error, if applicable. 27 | * @param type the type of error that occurred. 28 | */ 29 | @Serializable 30 | data class OpenAIErrorDetails( 31 | @SerialName("code") val code: String?, 32 | @SerialName("message") val message: String?, 33 | @SerialName("param") val param: String? = null, 34 | @SerialName("type") val type: String? = null, 35 | ) 36 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/common/network/api/ktor/internal/exception/OpenLLMException.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal.exception 2 | 3 | /** OpenAI client exception */ 4 | sealed class OpenAIException( 5 | message: String? = null, 6 | throwable: Throwable? = null, 7 | ) : RuntimeException(message, throwable) -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/tddworks/di/Koin.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.di 2 | 3 | import com.tddworks.common.network.api.ktor.internal.JsonLenient 4 | import org.koin.core.component.KoinComponent 5 | import org.koin.core.component.inject 6 | import org.koin.core.context.startKoin 7 | import org.koin.core.module.dsl.singleOf 8 | import org.koin.dsl.KoinAppDeclaration 9 | import org.koin.dsl.module 10 | 11 | fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) = 12 | startKoin { 13 | appDeclaration() 14 | modules(commonModule(enableNetworkLogs = enableNetworkLogs)) 15 | } 16 | 17 | //TODO - enableNetworkLogs is not used 18 | fun commonModule(enableNetworkLogs: Boolean) = module { 19 | singleOf(::createJson) 20 | } 21 | 22 | fun createJson() = JsonLenient 23 | 24 | 25 | inline fun getInstance(): T { 26 | return object : KoinComponent { 27 | val value: T by inject() 28 | }.value 29 | } -------------------------------------------------------------------------------- /common/src/jvmMain/kotlin/com/tddworks/common/network/api/ktor/internal/HttpClient.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | import io.ktor.client.engine.* 4 | import io.ktor.client.engine.okhttp.* 5 | 6 | 7 | internal actual fun httpClientEngine(): HttpClientEngine { 8 | return OkHttp.create() 9 | } -------------------------------------------------------------------------------- /common/src/jvmTest/kotlin/com/tddworks/common/network/api/InternalPackageTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api 2 | 3 | import com.tngtech.archunit.core.importer.ClassFileImporter 4 | import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.jupiter.api.Test 7 | import org.reflections.Reflections 8 | import org.reflections.scanners.Scanners 9 | import java.io.IOException 10 | 11 | class InternalPackageTest { 12 | 13 | companion object { 14 | private val BASE_PACKAGE = InternalPackageTest::class.java.`package`.name 15 | } 16 | 17 | private val analyzedClasses = ClassFileImporter().importPackages(BASE_PACKAGE) 18 | 19 | @Test 20 | @Throws(IOException::class) 21 | fun internalPackagesAreNotAccessedFromOutside() { 22 | 23 | // so that the test will break when the base package is re-named 24 | assertPackageExists(BASE_PACKAGE) 25 | val internalPackages = internalPackages(BASE_PACKAGE) 26 | for (internalPackage in internalPackages) { 27 | assertPackageExists(internalPackage) 28 | assertPackageIsNotAccessedFromOutside(internalPackage) 29 | } 30 | } 31 | 32 | /** 33 | * Finds all packages named "internal". 34 | */ 35 | private fun internalPackages(basePackage: String): List { 36 | val scanner = Scanners.SubTypes 37 | val reflections = Reflections(basePackage, scanner) 38 | return reflections.getSubTypesOf(Object::class.java).map { 39 | it.`package`.name 40 | }.filter { 41 | it.endsWith(".internal") 42 | } 43 | } 44 | 45 | private fun assertPackageIsNotAccessedFromOutside(internalPackage: String) { 46 | noClasses() 47 | .that() 48 | .resideOutsideOfPackage(packageMatcher(internalPackage)) 49 | .should() 50 | .dependOnClassesThat() 51 | .resideInAPackage(packageMatcher(internalPackage)) 52 | .check(analyzedClasses) 53 | } 54 | 55 | private fun assertPackageExists(packageName: String?) { 56 | assertThat(analyzedClasses.containPackage(packageName)) 57 | .`as`("package %s exists", packageName) 58 | .isTrue() 59 | } 60 | 61 | private fun packageMatcher(fullyQualifiedPackage: String): String? { 62 | return "$fullyQualifiedPackage.." 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /common/src/jvmTest/kotlin/com/tddworks/common/network/api/JsonUtils.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | val prettyJson = Json { // this returns the JsonBuilder 6 | prettyPrint = true 7 | ignoreUnknownKeys = true 8 | // optional: specify indent 9 | prettyPrintIndent = " " 10 | } 11 | 12 | /** 13 | * Represents a JSON object that allows for leniency and ignores unknown keys. 14 | * 15 | * @property isLenient Removes JSON specification restriction (RFC-4627) and makes parser more liberal to the malformed input. In lenient mode quoted boolean literals, and unquoted string literals are allowed. 16 | * Its relaxations can be expanded in the future, so that lenient parser becomes even more permissive to invalid value in the input, replacing them with defaults. 17 | * false by default. 18 | * @property ignoreUnknownKeys Specifies whether encounters of unknown properties in the input JSON should be ignored instead of throwing SerializationException. false by default.. 19 | */ 20 | internal val JsonLenient = Json { 21 | isLenient = true 22 | ignoreUnknownKeys = true 23 | } -------------------------------------------------------------------------------- /common/src/jvmTest/kotlin/com/tddworks/common/network/api/MockHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api 2 | 3 | 4 | import io.ktor.client.* 5 | import io.ktor.client.engine.mock.* 6 | import io.ktor.client.plugins.* 7 | import io.ktor.client.plugins.contentnegotiation.* 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | import io.ktor.serialization.kotlinx.* 11 | import io.ktor.utils.io.* 12 | 13 | /** 14 | * See https://ktor.io/docs/http-client-testing.html#usage 15 | */ 16 | fun mockHttpClient( 17 | mockResponse: String, 18 | mockHttpStatusCode: HttpStatusCode = HttpStatusCode.OK, 19 | exception: Exception? = null 20 | ) = HttpClient(MockEngine) { 21 | 22 | val headers = 23 | headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) 24 | 25 | install(ContentNegotiation) { 26 | register(ContentType.Application.Json, KotlinxSerializationConverter(JsonLenient)) 27 | } 28 | 29 | engine { 30 | addHandler { request -> 31 | 32 | exception?.let { 33 | throw it 34 | } 35 | 36 | if (mockHttpStatusCode == HttpStatusCode.OK || mockHttpStatusCode == HttpStatusCode.Forbidden) { 37 | if (request.url.encodedPath == "/v1/chat/completions" 38 | || request.url.encodedPath == "/v1/images/generations" 39 | ) { 40 | respond(mockResponse, mockHttpStatusCode, headers) 41 | } else { 42 | error("Unhandled ${request.url.encodedPath}") 43 | } 44 | } else { 45 | respondError(mockHttpStatusCode) 46 | } 47 | } 48 | } 49 | 50 | defaultRequest { 51 | url { 52 | protocol = URLProtocol.HTTPS 53 | host = "some-host" 54 | } 55 | 56 | header(HttpHeaders.ContentType, ContentType.Application.Json) 57 | contentType(ContentType.Application.Json) 58 | } 59 | } -------------------------------------------------------------------------------- /common/src/jvmTest/kotlin/com/tddworks/common/network/api/ktor/StreamResponse.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class StreamResponse( 7 | val content: String 8 | ) -------------------------------------------------------------------------------- /common/src/jvmTest/kotlin/com/tddworks/common/network/api/ktor/TestKoinCoroutineExtension.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.test.StandardTestDispatcher 5 | import kotlinx.coroutines.test.TestDispatcher 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.setMain 8 | import org.junit.jupiter.api.extension.AfterEachCallback 9 | import org.junit.jupiter.api.extension.BeforeEachCallback 10 | import org.junit.jupiter.api.extension.ExtensionContext 11 | 12 | class TestKoinCoroutineExtension(private val testDispatcher: TestDispatcher = StandardTestDispatcher()) : 13 | BeforeEachCallback, AfterEachCallback { 14 | override fun beforeEach(context: ExtensionContext?) { 15 | Dispatchers.setMain(testDispatcher) 16 | } 17 | 18 | override fun afterEach(context: ExtensionContext?) { 19 | Dispatchers.resetMain() 20 | } 21 | } -------------------------------------------------------------------------------- /common/src/jvmTest/kotlin/com/tddworks/common/network/api/ktor/api/StreamTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.api 2 | 3 | import app.cash.turbine.test 4 | import com.tddworks.common.network.api.ktor.StreamResponse 5 | import com.tddworks.common.network.api.ktor.internal.JsonLenient 6 | import io.ktor.client.* 7 | import io.ktor.client.engine.mock.* 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | import io.ktor.utils.io.* 11 | import kotlinx.coroutines.flow.flow 12 | import kotlinx.coroutines.runBlocking 13 | import kotlinx.serialization.json.Json 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.extension.RegisterExtension 16 | import org.koin.dsl.module 17 | import org.koin.test.junit5.AutoCloseKoinTest 18 | import org.koin.test.junit5.KoinTestExtension 19 | import kotlin.test.assertEquals 20 | 21 | class StreamTest : AutoCloseKoinTest() { 22 | 23 | @JvmField 24 | @RegisterExtension 25 | val koinTestExtension = KoinTestExtension.create { 26 | modules( 27 | module { 28 | single { JsonLenient } 29 | }) 30 | } 31 | 32 | @Test 33 | fun `test streamEventsFrom with stream response`(): Unit = runBlocking { 34 | val channel = ByteChannel(autoFlush = true) 35 | val mockEngine = MockEngine { request -> 36 | when (request.url.toString()) { 37 | "http://example.com/stream" -> 38 | respond( 39 | content = channel, 40 | status = HttpStatusCode.OK 41 | ) 42 | 43 | else -> respond("", HttpStatusCode.NotFound) 44 | } 45 | } 46 | 47 | val client = HttpClient(mockEngine) 48 | 49 | val content = flow { 50 | client.preparePost("http://example.com/stream").execute { 51 | streamEventsFrom(it) 52 | } 53 | } 54 | 55 | channel.writeStringUtf8("Hello world!\n") 56 | channel.writeStringUtf8("data: {\"content\": \"some-content-1\"}\n") 57 | channel.writeStringUtf8("data: {\"content\": \"some-content-2\"}\n") 58 | channel.writeStringUtf8("data: [DONE]\n") 59 | content.test { 60 | assertEquals(StreamResponse("some-content-1"), awaitItem()) 61 | assertEquals(StreamResponse("some-content-2"), awaitItem()) 62 | awaitComplete() 63 | channel.close() 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /common/src/jvmTest/kotlin/com/tddworks/di/CommonKoinTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.di 2 | 3 | import kotlinx.serialization.json.Json 4 | import org.junit.jupiter.api.Test 5 | import org.koin.dsl.koinApplication 6 | import org.koin.test.check.checkModules 7 | import org.koin.test.junit5.AutoCloseKoinTest 8 | import kotlin.test.assertNotNull 9 | 10 | class CommonKoinTest : AutoCloseKoinTest() { 11 | @Test 12 | fun `should initialize common koin modules`() { 13 | koinApplication { 14 | initKoin { } 15 | }.checkModules() 16 | 17 | val json = getInstance() 18 | 19 | assertNotNull(json) 20 | } 21 | } -------------------------------------------------------------------------------- /gemini-client/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `maven-publish` 3 | } 4 | 5 | kotlin { 6 | jvm() 7 | sourceSets { 8 | commonMain { 9 | dependencies { 10 | api(projects.geminiClient.geminiClientCore) 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.google.devtools.ksp.gradle.KspTaskMetadata 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 3 | 4 | plugins { 5 | alias(libs.plugins.kotlinx.serialization) 6 | alias(libs.plugins.kover) 7 | alias(libs.plugins.ksp) 8 | `maven-publish` 9 | } 10 | 11 | kotlin { 12 | jvm() 13 | iosArm64() 14 | iosSimulatorArm64() 15 | macosArm64() 16 | 17 | sourceSets { 18 | commonMain { 19 | // https://github.com/google/ksp/issues/963#issuecomment-1894144639 20 | tasks.withType { kotlin.srcDir(destinationDirectory) } 21 | 22 | kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") 23 | dependencies { 24 | // put your Multiplatform dependencies here 25 | api(projects.common) 26 | } 27 | } 28 | 29 | commonTest.dependencies { 30 | implementation(libs.ktor.client.mock) 31 | api(projects.common) 32 | } 33 | 34 | macosMain.dependencies { 35 | api(libs.ktor.client.darwin) 36 | } 37 | 38 | jvmMain.dependencies { 39 | api(libs.ktor.client.cio) 40 | } 41 | 42 | jvmTest.dependencies { 43 | implementation(project.dependencies.platform(libs.junit.bom)) 44 | implementation(libs.bundles.jvm.test) 45 | implementation(libs.kotlinx.coroutines.test) 46 | implementation(libs.koin.test) 47 | implementation(libs.koin.test.junit5) 48 | implementation(libs.app.cash.turbine) 49 | implementation("com.tngtech.archunit:archunit-junit5:1.1.0") 50 | implementation("org.reflections:reflections:0.10.2") 51 | implementation(libs.org.skyscreamer.jsonassert) 52 | } 53 | } 54 | } 55 | 56 | // KSP Tasks 57 | dependencies { 58 | add("kspCommonMainMetadata", libs.koin.ksp.compiler) 59 | } 60 | 61 | // WORKAROUND: ADD this dependsOn("kspCommonMainKotlinMetadata") instead of above dependencies 62 | tasks.withType().configureEach { 63 | if (name != "kspCommonMainKotlinMetadata") { 64 | dependsOn("kspCommonMainKotlinMetadata") 65 | } 66 | } 67 | 68 | 69 | ksp { 70 | arg("KOIN_DEFAULT_MODULE", "false") 71 | // https://insert-koin.io/docs/reference/koin-annotations/start#compile-safety---check-your-koin-config-at-compile-time-since-130 72 | arg("KOIN_CONFIG_CHECK", "false") 73 | } 74 | 75 | tasks { 76 | named("jvmTest") { 77 | useJUnitPlatform() 78 | } 79 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/Gemini.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import com.tddworks.di.getInstance 4 | 5 | interface Gemini : TextGeneration { 6 | companion object { 7 | const val HOST = "generativelanguage.googleapis.com" 8 | const val BASE_URL = "https://$HOST" 9 | 10 | 11 | fun default(): Gemini { 12 | return object : Gemini, TextGeneration by getInstance() {} 13 | } 14 | 15 | fun create(config: GeminiConfig): Gemini { 16 | return object : Gemini, TextGeneration by getInstance() {} 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GeminiConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | data class GeminiConfig( 4 | val apiKey: () -> String = { "CONFIG_API_KEY" }, 5 | val baseUrl: () -> String = { Gemini.BASE_URL }, 6 | ) 7 | -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GeminiModel.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | /** 7 | * https://ai.google.dev/gemini-api/docs/models/gemini 8 | */ 9 | @Serializable 10 | @JvmInline 11 | value class GeminiModel(val value: String) { 12 | companion object { 13 | 14 | /** 15 | * Input(s): Audio, images, videos, and text 16 | * Output(s): Text, images (coming soon), and audio (coming soon) 17 | * Optimized for: Next generation features, speed, and multimodal generation for a diverse variety of tasks 18 | */ 19 | val GEMINI_2_0_FLASH = GeminiModel("gemini-2.0-flash") 20 | 21 | /** 22 | * Audio, images, videos, and text 23 | * Output(s): Text 24 | * A Gemini 2.0 Flash model optimized for cost efficiency and low latency 25 | */ 26 | val GEMINI_2_0_FLASH_LITE = GeminiModel("gemini-2.0-flash-lite-preview-02-05") 27 | 28 | /** 29 | * Input(s): Audio, images, videos, and text 30 | * Output(s): Text 31 | * Optimized for: Complex reasoning tasks requiring more intelligence 32 | */ 33 | val GEMINI_1_5_PRO = GeminiModel("gemini-1.5-pro") 34 | 35 | /** 36 | * Input(s): Audio, images, videos, and text 37 | * Output(s): Text 38 | * Optimized for: High volume and lower intelligence tasks 39 | */ 40 | val GEMINI_1_5_FLASH_8b = GeminiModel("gemini-1.5-flash-8b") 41 | 42 | /** 43 | * Input(s): Audio, images, videos, and text 44 | * Output(s): Text 45 | * Optimized for: Fast and versatile performance across a diverse variety of tasks 46 | */ 47 | val GEMINI_1_5_FLASH = GeminiModel("gemini-1.5-flash") 48 | 49 | val availableModels = listOf( 50 | GEMINI_1_5_PRO, 51 | GEMINI_1_5_FLASH_8b, 52 | GEMINI_1_5_FLASH, 53 | GEMINI_2_0_FLASH, 54 | GEMINI_2_0_FLASH_LITE 55 | ) 56 | } 57 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentRequest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import com.tddworks.gemini.api.textGeneration.api.internal.DefaultTextGenerationApi.Companion.GEMINI_API_PATH 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | import kotlinx.serialization.Transient 7 | 8 | // curl https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:streamGenerateContent?alt=sse&key=$GOOGLE_API_KEY \ 9 | // curl https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=$GOOGLE_API_KEY \ 10 | 11 | /** 12 | * { 13 | * "contents": [ 14 | * {"role":"user", 15 | * "parts":[{ 16 | * "text": "Hello"}]}, 17 | * {"role": "model", 18 | * "parts":[{ 19 | * "text": "Great to meet you. What would you like to know?"}]}, 20 | * {"role":"user", 21 | * "parts":[{ 22 | * "text": "I have two dogs in my house. How many paws are in my house?"}]}, 23 | * ] 24 | * } 25 | */ 26 | @Serializable 27 | data class GenerateContentRequest( 28 | @SerialName("system_instruction") 29 | val systemInstruction: Content? = null, 30 | val contents: List, 31 | val generationConfig: GenerationConfig? = null, 32 | @Transient val model: GeminiModel = GeminiModel.GEMINI_1_5_FLASH, 33 | @Transient val stream: Boolean = false 34 | ) { 35 | fun toRequestUrl(): String { 36 | val endpoint = if (stream) { 37 | "streamGenerateContent" 38 | } else { 39 | "generateContent" 40 | } 41 | return "$GEMINI_API_PATH/${model.value}:$endpoint" 42 | } 43 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentResponse.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | internal typealias Base64String = String 7 | 8 | @Serializable 9 | data class GenerateContentResponse( 10 | val candidates: List, 11 | val usageMetadata: UsageMetadata, 12 | val modelVersion: String 13 | ) { 14 | companion object { 15 | fun dummy() = GenerateContentResponse( 16 | candidates = emptyList(), 17 | usageMetadata = UsageMetadata(0, 0, 0), 18 | modelVersion = "" 19 | ) 20 | } 21 | } 22 | 23 | @Serializable 24 | data class Candidate( 25 | val content: Content, 26 | val finishReason: String? = null, 27 | val avgLogprobs: Double? = null 28 | ) 29 | 30 | @Serializable 31 | data class Content( 32 | val parts: List, 33 | val role: String? = null 34 | ) 35 | 36 | @Serializable(with = PartSerializer::class) 37 | sealed interface Part { 38 | 39 | @Serializable 40 | data class TextPart( 41 | val text: String 42 | ) : Part 43 | 44 | /** 45 | * https://ai.google.dev/gemini-api/docs/text-generation?lang=rest#generate-text-from-text-and-image 46 | * @param mimeType The MIME type of the data. Supported MIME types are the following: 47 | * - image/jpeg 48 | * - image/png 49 | * @param data The base64-encoded data. 50 | */ 51 | @Serializable 52 | data class InlineDataPart( 53 | @SerialName("inline_data") val inlineData: InlineData 54 | ) : Part { 55 | @Serializable 56 | data class InlineData( 57 | @SerialName("mime_type") val mimeType: String, 58 | val data: Base64String 59 | ) 60 | } 61 | } 62 | 63 | 64 | @Serializable 65 | data class UsageMetadata( 66 | val promptTokenCount: Int, 67 | val candidatesTokenCount: Int? = null, 68 | val totalTokenCount: Int 69 | ) 70 | -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerationConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * { 8 | * "contents": [{ 9 | * "parts": [{ 10 | * "text": "Write a story about a magic backpack." 11 | * }] 12 | * }], 13 | * "safetySettings": [{ 14 | * "category": "HARM_CATEGORY_DANGEROUS_CONTENT", 15 | * "threshold": "BLOCK_ONLY_HIGH" 16 | * }], 17 | * "generationConfig": { 18 | * "stopSequences": [ 19 | * "Title" 20 | * ], 21 | * "temperature": 1.0, 22 | * "response_mime_type": "application/json", 23 | * "maxOutputTokens": 800, 24 | * "topP": 0.8, 25 | * "topK": 10 26 | * } 27 | * } 28 | */ 29 | @Serializable 30 | data class GenerationConfig( 31 | val temperature: Float? = null, 32 | @SerialName("response_mime_type") val responseMimeType: String? = null 33 | ) 34 | -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/PartSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.SerializationException 5 | import kotlinx.serialization.json.JsonContentPolymorphicSerializer 6 | import kotlinx.serialization.json.JsonElement 7 | import kotlinx.serialization.json.jsonObject 8 | 9 | /** 10 | * { 11 | * "contents": [{ 12 | * "parts":[ 13 | * {"text": "Tell me about this instrument"}, 14 | * { 15 | * "inline_data": { 16 | * "mime_type":"image/jpeg", 17 | * "data": "$(cat "$TEMP_B64")" 18 | * } 19 | * } 20 | * ] 21 | * }] 22 | * } 23 | */ 24 | object PartSerializer : 25 | JsonContentPolymorphicSerializer(Part::class) { 26 | override fun selectDeserializer(element: JsonElement): KSerializer { 27 | val jsonObject = element.jsonObject 28 | return when { 29 | "text" in jsonObject -> Part.TextPart.serializer() 30 | "inline_data" in jsonObject -> Part.InlineDataPart.serializer() 31 | else -> throw SerializationException("Unknown Part type") 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/TextGeneration.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | /** 6 | * https://ai.google.dev/api/generate-content#v1beta.Candidate 7 | */ 8 | interface TextGeneration { 9 | suspend fun generateContent(request: GenerateContentRequest): GenerateContentResponse 10 | 11 | /** 12 | * data: {"candidates": [{"content": {"parts": [{"text": " understand that AI is a constantly evolving field. New techniques and approaches are continually being developed, pushing the boundaries of what's possible. While AI can achieve impressive feats, it's important to remember that it's a tool, and its capabilities are limited by the data it's trained on and the algorithms"}],"role": "model"}}],"usageMetadata": {"promptTokenCount": 4,"totalTokenCount": 4},"modelVersion": "gemini-1.5-flash"} 13 | * 14 | * data: {"candidates": [{"content": {"parts": [{"text": " it uses. It doesn't possess consciousness or genuine understanding in the human sense.\n"}],"role": "model"},"finishReason": "STOP"}],"usageMetadata": {"promptTokenCount": 4,"candidatesTokenCount": 724,"totalTokenCount": 728},"modelVersion": "gemini-1.5-flash"} 15 | */ 16 | fun streamGenerateContent(request: GenerateContentRequest): Flow 17 | 18 | companion object 19 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/internal/DefaultTextGenerationApi.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api.internal 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.api.performRequest 5 | import com.tddworks.common.network.api.ktor.api.streamRequest 6 | import com.tddworks.gemini.api.textGeneration.api.GenerateContentRequest 7 | import com.tddworks.gemini.api.textGeneration.api.GenerateContentResponse 8 | import com.tddworks.gemini.api.textGeneration.api.TextGeneration 9 | import io.ktor.client.request.* 10 | import io.ktor.http.* 11 | import kotlinx.coroutines.flow.Flow 12 | import org.koin.core.annotation.Single 13 | 14 | @Single 15 | class DefaultTextGenerationApi( 16 | private val requester: HttpRequester 17 | ) : TextGeneration { 18 | override suspend fun generateContent(request: GenerateContentRequest): GenerateContentResponse { 19 | return requester.performRequest { 20 | configureRequest(request) 21 | } 22 | } 23 | 24 | 25 | override fun streamGenerateContent(request: GenerateContentRequest): Flow { 26 | return requester.streamRequest { 27 | configureRequest(request) 28 | } 29 | } 30 | 31 | private fun HttpRequestBuilder.configureRequest(request: GenerateContentRequest) { 32 | method = HttpMethod.Post 33 | url(path = request.toRequestUrl()) 34 | if (request.stream) { 35 | parameter("alt", "sse") 36 | } 37 | setBody(request) 38 | contentType(ContentType.Application.Json) 39 | } 40 | 41 | companion object { 42 | const val GEMINI_API_PATH = "/v1beta/models" 43 | } 44 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/di/GeminiModule.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.di 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.internal.ClientFeatures 5 | import com.tddworks.common.network.api.ktor.internal.UrlBasedConnectionConfig 6 | import com.tddworks.common.network.api.ktor.internal.createHttpClient 7 | import com.tddworks.common.network.api.ktor.internal.default 8 | import com.tddworks.di.createJson 9 | import com.tddworks.gemini.api.textGeneration.api.Gemini 10 | import com.tddworks.gemini.api.textGeneration.api.GeminiConfig 11 | import org.koin.core.annotation.ComponentScan 12 | import org.koin.core.annotation.Module 13 | import org.koin.dsl.module 14 | import org.koin.ksp.generated.module 15 | 16 | @Module 17 | @ComponentScan("com.tddworks.gemini") 18 | class GeminiModule { 19 | companion object { 20 | fun geminiModules(config: GeminiConfig) = module { 21 | single { 22 | HttpRequester.default( 23 | createHttpClient( 24 | connectionConfig = UrlBasedConnectionConfig(config.baseUrl), 25 | features = ClientFeatures( 26 | json = createJson(), 27 | queryParams = { mapOf("key" to config.apiKey()) } 28 | ) 29 | ) 30 | ) 31 | } 32 | 33 | includes(GeminiModule().module) 34 | 35 | single { Gemini.default() } 36 | } 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/di/Koin.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.di 2 | 3 | import com.tddworks.di.commonModule 4 | import com.tddworks.gemini.api.textGeneration.api.GeminiConfig 5 | import com.tddworks.gemini.di.GeminiModule.Companion.geminiModules 6 | import org.koin.core.context.startKoin 7 | import org.koin.dsl.KoinAppDeclaration 8 | 9 | fun initGemini( 10 | config: GeminiConfig, 11 | enableNetworkLogs: Boolean = false, 12 | appDeclaration: KoinAppDeclaration = {} 13 | ) = startKoin { 14 | appDeclaration() 15 | modules( 16 | commonModule(enableNetworkLogs), 17 | geminiModules(config) 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/GeminiTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api 2 | 3 | import com.tddworks.di.getInstance 4 | import com.tddworks.gemini.api.textGeneration.api.Gemini 5 | import com.tddworks.gemini.api.textGeneration.api.GeminiConfig 6 | import com.tddworks.gemini.di.initGemini 7 | import org.junit.jupiter.api.Assertions.* 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | import org.koin.test.junit5.AutoCloseKoinTest 11 | 12 | 13 | class GeminiTest : AutoCloseKoinTest() { 14 | 15 | @BeforeEach 16 | fun setUp() { 17 | initGemini(config = GeminiConfig()) 18 | } 19 | 20 | @Test 21 | fun `should get gemini default api`() { 22 | val gemini = getInstance() 23 | 24 | assertNotNull(gemini) 25 | } 26 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentResponseTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import kotlinx.serialization.json.Json 4 | import org.junit.jupiter.api.Test 5 | import kotlin.test.assertEquals 6 | 7 | class GenerateContentResponseTest { 8 | 9 | @Test 10 | fun `should deserialize GenerateContentResponse`() { 11 | // Given 12 | val json = """ 13 | { 14 | "candidates": [ 15 | { 16 | "content": { 17 | "parts": [ 18 | { 19 | "text": "some-text" 20 | } 21 | ], 22 | "role": "model" 23 | }, 24 | "finishReason": "STOP", 25 | "avgLogprobs": -0.24741496906413898 26 | } 27 | ], 28 | "usageMetadata": { 29 | "promptTokenCount": 4, 30 | "candidatesTokenCount": 715, 31 | "totalTokenCount": 719 32 | }, 33 | "modelVersion": "gemini-1.5-flash" 34 | } 35 | """.trimIndent() 36 | 37 | // When 38 | val response = Json.decodeFromString(json) 39 | 40 | // Then 41 | assertEquals(1, response.candidates.size) 42 | assertEquals("gemini-1.5-flash", response.modelVersion) 43 | assertEquals(4, response.usageMetadata.promptTokenCount) 44 | assertEquals(715, response.usageMetadata.candidatesTokenCount) 45 | assertEquals(719, response.usageMetadata.totalTokenCount) 46 | assertEquals("STOP", response.candidates[0].finishReason) 47 | assertEquals(-0.24741496906413898, response.candidates[0].avgLogprobs) 48 | assertEquals("model", response.candidates[0].content.role) 49 | assertEquals(1, response.candidates[0].content.parts.size) 50 | assertEquals("some-text", (response.candidates[0].content.parts[0] as Part.TextPart).text) 51 | } 52 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerationConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import kotlinx.serialization.json.Json 4 | import org.junit.jupiter.api.Test 5 | import org.skyscreamer.jsonassert.JSONAssert 6 | 7 | class GenerationConfigTest { 8 | 9 | @Test 10 | fun `should return correct generation config`() { 11 | // Given 12 | val generationConfig = GenerationConfig( 13 | temperature = 1.0f, 14 | responseMimeType = "application/json" 15 | ) 16 | 17 | // When 18 | val result = Json.encodeToString( 19 | GenerationConfig.serializer(), 20 | generationConfig 21 | ) 22 | 23 | // Then 24 | JSONAssert.assertEquals( 25 | """ 26 | { 27 | "temperature": 1.0, 28 | "response_mime_type": "application/json" 29 | } 30 | """.trimIndent(), 31 | result, 32 | false 33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/MockHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api.textGeneration.api 2 | 3 | import com.tddworks.common.network.api.ktor.internal.JsonLenient 4 | import io.ktor.client.* 5 | import io.ktor.client.engine.mock.* 6 | import io.ktor.client.plugins.* 7 | import io.ktor.client.plugins.contentnegotiation.* 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | import io.ktor.serialization.kotlinx.* 11 | 12 | /** 13 | * See https://ktor.io/docs/http-client-testing.html#usage 14 | */ 15 | fun mockHttpClient(mockResponse: String) = HttpClient(MockEngine) { 16 | 17 | val headers = 18 | headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) 19 | 20 | install(ContentNegotiation) { 21 | register(ContentType.Application.Json, KotlinxSerializationConverter(JsonLenient)) 22 | } 23 | 24 | engine { 25 | addHandler { request -> 26 | when (request.url.encodedPath) { 27 | "/v1beta/models/gemini-1.5-flash:generateContent" -> { 28 | respond(mockResponse, HttpStatusCode.OK, headers) 29 | } 30 | 31 | "/v1beta/models/gemini-1.5-flash:streamGenerateContent" -> { 32 | respond(mockResponse, HttpStatusCode.OK, headers) 33 | } 34 | 35 | else -> { 36 | error("Unhandled ${request.url.encodedPath}") 37 | } 38 | } 39 | } 40 | } 41 | 42 | defaultRequest { 43 | url { 44 | protocol = URLProtocol.HTTPS 45 | host = Gemini.HOST 46 | } 47 | 48 | header(HttpHeaders.ContentType, ContentType.Application.Json) 49 | contentType(ContentType.Application.Json) 50 | } 51 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-darwin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.touchlab.kmmbridge) 4 | alias(libs.plugins.touchlab.skie) 5 | `maven-publish` 6 | } 7 | 8 | kotlin { 9 | listOf( 10 | macosArm64(), 11 | iosArm64(), 12 | iosSimulatorArm64() 13 | ).forEach { macosTarget -> 14 | macosTarget.binaries.framework { 15 | export(projects.geminiClient.geminiClientCore) 16 | baseName = "GeminiClient" 17 | isStatic = true 18 | } 19 | } 20 | 21 | sourceSets { 22 | commonMain { 23 | dependencies { 24 | api(projects.geminiClient.geminiClientCore) 25 | implementation(libs.ktor.client.darwin) 26 | } 27 | } 28 | appleMain {} 29 | } 30 | } 31 | 32 | addGithubPackagesRepository() // <- Add the GitHub Packages repo 33 | 34 | 35 | kmmbridge { 36 | /** 37 | * reference: https://kmmbridge.touchlab.co/docs/artifacts/MAVEN_REPO_ARTIFACTS#github-packages 38 | * In kmmbridge, notice mavenPublishArtifacts() tells the plugin to push KMMBridge artifacts to a Maven repo. You then need to define a repo. Rather than do everything manually, you can just call addGithubPackagesRepository(), which will add the correct repo given parameters that are passed in from GitHub Actions. 39 | */ 40 | mavenPublishArtifacts() // <- Publish using a Maven repo 41 | /** 42 | * https://github.com/touchlab/KMMBridge/issues/258 43 | */ 44 | spm( 45 | swiftToolVersion = "5.9", 46 | // useCustomPackageFile = true, 47 | // perModuleVariablesBlock = true 48 | ) { 49 | iOS { v("15") } 50 | // macOS { v("15") } 51 | } 52 | } -------------------------------------------------------------------------------- /gemini-client/gemini-client-darwin/src/appleMain/kotlin/com/tddworks/gemini/api/DarwinGemini.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.gemini.api 2 | 3 | import com.tddworks.gemini.api.textGeneration.api.Gemini 4 | import com.tddworks.gemini.api.textGeneration.api.GeminiConfig 5 | import com.tddworks.gemini.di.initGemini 6 | 7 | /** 8 | * A singleton object that initializes the Gemini configuration with the specified API key and base URL. 9 | */ 10 | object DarwinGemini { 11 | 12 | /** 13 | * Initializes the Gemini configuration with the specified API key and base URL. 14 | * 15 | * This function sets up the Gemini environment by creating a configuration using the provided 16 | * API key and base URL, then initializing Gemini with this configuration. 17 | * 18 | * @param apiKey A lambda function that returns the API key as a string. Defaults to returning "CONFIG_API_KEY". 19 | * @param baseUrl A lambda function that returns the base URL as a string. Defaults to returning `Gemini.BASE_URL`. 20 | * 21 | * @return The initialized Gemini configuration. 22 | */ 23 | fun gemini( 24 | apiKey: () -> String = { "CONFIG_API_KEY" }, 25 | baseUrl: () -> String = { Gemini.BASE_URL }, 26 | ): Gemini = initGemini(GeminiConfig(apiKey, baseUrl)).koin.get() 27 | } 28 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx4048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4048M" 3 | org.gradle.caching=true 4 | org.gradle.configuration-cache=false 5 | #Kotlin 6 | kotlin.code.style=official 7 | kotlin.js.compiler=ir 8 | #MPP 9 | kotlin.mpp.enableCInteropCommonization=true 10 | #Android 11 | android.useAndroidX=true 12 | android.nonTransitiveRClass=true 13 | 14 | #publishing for kmmbridge 15 | ## Darwin Publish require from - nextVersion parameter must be a valid semver string. Current value: 0.1.4. 16 | ## So we need set version to 0.1 or 0.2 ...... 17 | LIBRARY_VERSION=0.2.3 18 | GROUP=com.tddworks 19 | 20 | # POM 21 | POM_NAME=OpenAI Kotlin 22 | POM_DESCRIPTION=OpenAI API KMP Client 23 | POM_URL=https://github.com/tddworks/openai-kotlin 24 | POM_SCM_URL=https://github.com/tddworks/openai-kotlin 25 | POM_SCM_CONNECTION=scm:git:git://github.com/tddworks/openai-kotlin.git 26 | POM_SCM_DEV_CONNECTION=scm:git:ssh://github.com/tddworks/openai-kotlin.git 27 | POM_LICENCE_NAME=Apache License 2.0 28 | POM_LICENCE_URL=https://github.com/tddworks/openai-kotlin/blob/main/LICENSE 29 | POM_LICENCE_DIST=repo 30 | POM_DEVELOPER_ID=tddworks 31 | POM_DEVELOPER_ORGANIZATION=tddworks 32 | POM_DEVELOPER_ORGANIZATION_URL=https://tddworks.com 33 | POM_DEVELOPER_NAME=itshan 34 | POM_DEVELOPER_EMAIL=itshan@ttdworks.com 35 | POM_ISSUE_SYSTEM=github 36 | POM_ISSUE_URL=https://github.com/tddworks/openai-kotlin/issues 37 | 38 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tddworks/openai-kotlin/02e262493ef8b80ee89dd6ca8e705efd136fef13/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /ollama-client/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `maven-publish` 3 | } 4 | 5 | kotlin { 6 | jvm() 7 | sourceSets { 8 | commonMain { 9 | dependencies { 10 | api(projects.ollamaClient.ollamaClientCore) 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /ollama-client/jvm/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tddworks/openai-kotlin/02e262493ef8b80ee89dd6ca8e705efd136fef13/ollama-client/jvm/.gitkeep -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.kover) 4 | `maven-publish` 5 | } 6 | 7 | kotlin { 8 | jvm() 9 | macosArm64() 10 | iosArm64() 11 | iosSimulatorArm64() 12 | 13 | sourceSets { 14 | commonMain.dependencies { 15 | // put your Multiplatform dependencies here 16 | api(projects.common) 17 | } 18 | 19 | commonTest.dependencies { 20 | implementation(libs.ktor.client.mock) 21 | api(projects.common) 22 | } 23 | 24 | macosMain.dependencies { 25 | api(libs.ktor.client.darwin) 26 | } 27 | 28 | jvmMain.dependencies { 29 | api(libs.ktor.client.cio) 30 | } 31 | 32 | jvmTest.dependencies { 33 | implementation(project.dependencies.platform(libs.junit.bom)) 34 | implementation(libs.bundles.jvm.test) 35 | implementation(libs.kotlinx.coroutines.test) 36 | implementation(libs.koin.test) 37 | implementation(libs.koin.test.junit5) 38 | implementation(libs.app.cash.turbine) 39 | implementation("com.tngtech.archunit:archunit-junit5:1.1.0") 40 | implementation("org.reflections:reflections:0.10.2") 41 | } 42 | } 43 | } 44 | 45 | tasks { 46 | named("jvmTest") { 47 | useJUnitPlatform() 48 | } 49 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/Ollama.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.internal.* 5 | import com.tddworks.ollama.api.chat.OllamaChat 6 | import com.tddworks.ollama.api.chat.internal.DefaultOllamaChatApi 7 | import com.tddworks.ollama.api.generate.OllamaGenerate 8 | import com.tddworks.ollama.api.generate.internal.DefaultOllamaGenerateApi 9 | import com.tddworks.ollama.api.internal.OllamaApi 10 | import com.tddworks.ollama.api.json.JsonLenient 11 | 12 | /** 13 | * Interface for interacting with the Ollama API. 14 | */ 15 | interface Ollama : OllamaChat, OllamaGenerate { 16 | 17 | companion object { 18 | const val BASE_URL = "localhost" 19 | const val PORT = 11434 20 | const val PROTOCOL = "http" 21 | 22 | fun create(ollamaConfig: OllamaConfig): Ollama { 23 | val requester = HttpRequester.default( 24 | createHttpClient( 25 | connectionConfig = HostPortConnectionConfig( 26 | protocol = ollamaConfig.protocol, 27 | port = ollamaConfig.port, 28 | host = ollamaConfig.baseUrl 29 | ), 30 | // get from commonModule 31 | features = ClientFeatures(json = JsonLenient) 32 | ) 33 | ) 34 | val ollamaChat = DefaultOllamaChatApi(requester = requester) 35 | val ollamaGenerate = DefaultOllamaGenerateApi(requester = requester) 36 | 37 | return OllamaApi( 38 | config = ollamaConfig, 39 | ollamaChat = ollamaChat, 40 | ollamaGenerate = ollamaGenerate 41 | ) 42 | } 43 | } 44 | 45 | /** 46 | * This function returns the base URL as a string. 47 | * 48 | * @return a string representing the base URL 49 | */ 50 | fun baseUrl(): String 51 | 52 | /** 53 | * This function returns the port as an integer. 54 | * 55 | * @return an integer representing the port 56 | */ 57 | fun port(): Int 58 | 59 | /** 60 | * This function returns the protocol as a string. 61 | * 62 | * @return a string representing the protocol 63 | */ 64 | fun protocol(): String 65 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/OllamaConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import org.koin.core.component.KoinComponent 4 | 5 | data class OllamaConfig( 6 | val baseUrl: () -> String = { Ollama.BASE_URL }, 7 | val protocol: () -> String = { Ollama.PROTOCOL }, 8 | val port: () -> Int = { Ollama.PORT }, 9 | ) : KoinComponent -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/OllamaModel.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | @Serializable 7 | @JvmInline 8 | value class OllamaModel(val value: String) { 9 | companion object { 10 | val LLAMA2 = OllamaModel("llama2") 11 | val LLAMA3 = OllamaModel("llama3") 12 | val CODE_LLAMA = OllamaModel("codellama") 13 | val DEEPSEEK_CODER = OllamaModel("deepseek-coder:6.7b-base") 14 | val MISTRAL = OllamaModel("mistral") 15 | val availableModels = listOf(LLAMA2, LLAMA3, CODE_LLAMA, MISTRAL, DEEPSEEK_CODER) 16 | } 17 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/chat/OllamaChat.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.chat 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface OllamaChat { 6 | fun stream(request: OllamaChatRequest): Flow 7 | suspend fun request(request: OllamaChatRequest): OllamaChatResponse 8 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/chat/OllamaChatRequest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.chat 2 | 3 | import com.tddworks.common.network.api.ktor.api.AnySerial 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | /** 8 | * https://github.com/ollama/ollama/blob/main/docs/api.md 9 | * Generate a chat completion 10 | */ 11 | @Serializable 12 | data class OllamaChatRequest( 13 | /** 14 | * (required) the model name 15 | */ 16 | @SerialName("model") val model: String, 17 | /** 18 | * (required) a list of messages to send to the model 19 | */ 20 | @SerialName("messages") val messages: List, 21 | /** 22 | * Advanced parameters (optional): 23 | * the format to return a response in. Currently the only accepted value is json 24 | */ 25 | @SerialName("format") val format: String? = null, 26 | /** 27 | * additional model parameters listed in the documentation for the Modelfile such as temperature 28 | */ 29 | @SerialName("options") val options: Map? = null, 30 | /** 31 | * keep_alive: controls how long the model will stay loaded into memory following the request (default: 5m) 32 | */ 33 | @SerialName("keep_alive") val keepAlive: String? = null, 34 | /** 35 | * stream: if false the response will be returned as a single response object, rather than a stream of objects 36 | */ 37 | @SerialName("stream") 38 | val stream: Boolean? = null, 39 | ) { 40 | 41 | companion object { 42 | fun dummy() = OllamaChatRequest( 43 | model = "llama2", 44 | messages = listOf( 45 | OllamaChatMessage( 46 | role = "user", 47 | content = "Hello!" 48 | ) 49 | ) 50 | ) 51 | } 52 | } 53 | 54 | 55 | @Serializable 56 | data class OllamaChatMessage( 57 | /** 58 | * `role`: the role of the message, either `system`, `user` or `assistant` 59 | */ 60 | @SerialName("role") val role: String, 61 | @SerialName("content") val content: String, 62 | @SerialName("images") val images: List? = null, 63 | ) -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/chat/OllamaChatResponse.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.chat 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * { 8 | * "model": "llama2", 9 | * "created_at": "2023-08-04T08:52:19.385406455-07:00", 10 | * "message": { 11 | * "role": "assistant", 12 | * "content": "The" 13 | * }, 14 | * "done": false 15 | * } 16 | * ======== final response ======== 17 | * { 18 | * "model": "llama2", 19 | * "created_at": "2023-08-04T19:22:45.499127Z", 20 | * "done": true, 21 | * "total_duration": 8113331500, 22 | * "load_duration": 6396458, 23 | * "prompt_eval_count": 61, 24 | * "prompt_eval_duration": 398801000, 25 | * "eval_count": 468, 26 | * "eval_duration": 7701267000 27 | * } 28 | * 29 | * ======= Non-streaming response ======= 30 | * { 31 | * "model": "llama2", 32 | * "created_at": "2023-12-12T14:13:43.416799Z", 33 | * "message": { 34 | * "role": "assistant", 35 | * "content": "Hello! How are you today?" 36 | * }, 37 | * "done": true, 38 | * "total_duration": 5191566416, 39 | * "load_duration": 2154458, 40 | * "prompt_eval_count": 26, 41 | * "prompt_eval_duration": 383809000, 42 | * "eval_count": 298, 43 | * "eval_duration": 4799921000 44 | * } 45 | */ 46 | @Serializable 47 | data class OllamaChatResponse( 48 | @SerialName("model") val model: String, 49 | @SerialName("created_at") val createdAt: String, 50 | @SerialName("message") val message: OllamaChatMessage? = null, 51 | @SerialName("done") val done: Boolean, 52 | // Below are the fields that are for final response or non-streaming response 53 | @SerialName("total_duration") val totalDuration: Long? = null, 54 | @SerialName("load_duration") val loadDuration: Long? = null, 55 | @SerialName("prompt_eval_count") val promptEvalCount: Int? = null, 56 | @SerialName("prompt_eval_duration") val promptEvalDuration: Long? = null, 57 | @SerialName("eval_count") val evalCount: Int? = null, 58 | @SerialName("eval_duration") val evalDuration: Long? = null, 59 | ) { 60 | companion object { 61 | fun dummy() = OllamaChatResponse( 62 | model = "llama2", 63 | createdAt = "2023-08-04T08:52:19.385406455-07:00", 64 | message = OllamaChatMessage( 65 | role = "assistant", 66 | content = "The" 67 | ), 68 | done = false 69 | ) 70 | } 71 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/chat/internal/DefaultOllamaChatApi.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.chat.internal 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.api.performRequest 5 | import com.tddworks.common.network.api.ktor.api.streamRequest 6 | import com.tddworks.ollama.api.chat.OllamaChat 7 | import com.tddworks.ollama.api.chat.OllamaChatRequest 8 | import com.tddworks.ollama.api.chat.OllamaChatResponse 9 | import io.ktor.client.request.* 10 | import io.ktor.http.* 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.serialization.json.Json 13 | 14 | class DefaultOllamaChatApi( 15 | private val requester: HttpRequester 16 | ) : OllamaChat { 17 | override fun stream(request: OllamaChatRequest): Flow { 18 | return requester.streamRequest { 19 | method = HttpMethod.Post 20 | url(path = CHAT_API_PATH) 21 | setBody(request.copy(stream = true)) 22 | contentType(ContentType.Application.Json) 23 | accept(ContentType.Text.EventStream) 24 | headers { 25 | append(HttpHeaders.CacheControl, "no-cache") 26 | append(HttpHeaders.Connection, "keep-alive") 27 | } 28 | } 29 | } 30 | 31 | override suspend fun request(request: OllamaChatRequest): OllamaChatResponse { 32 | return requester.performRequest { 33 | method = HttpMethod.Post 34 | url(path = CHAT_API_PATH) 35 | setBody(request) 36 | contentType(ContentType.Application.Json) 37 | } 38 | } 39 | 40 | companion object { 41 | const val CHAT_API_PATH = "/api/chat" 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/generate/OllamaGenerate.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.generate 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | /** 6 | * Interface for Ollama generate 7 | */ 8 | interface OllamaGenerate { 9 | fun stream(request: OllamaGenerateRequest): Flow 10 | suspend fun request(request: OllamaGenerateRequest): OllamaGenerateResponse 11 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/generate/OllamaGenerateRequest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.generate 2 | 3 | import com.tddworks.common.network.api.ktor.api.AnySerial 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class OllamaGenerateRequest( 9 | @SerialName("model") val model: String, 10 | @SerialName("prompt") val prompt: String, 11 | @SerialName("stream") val stream: Boolean = false, 12 | @SerialName("raw") val raw: Boolean = false, 13 | @SerialName("options") val options: Map? = null, 14 | ) -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/generate/internal/DefaultOllamaGenerateApi.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.generate.internal 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.api.performRequest 5 | import com.tddworks.common.network.api.ktor.api.streamRequest 6 | import com.tddworks.ollama.api.generate.OllamaGenerate 7 | import com.tddworks.ollama.api.generate.OllamaGenerateRequest 8 | import com.tddworks.ollama.api.generate.OllamaGenerateResponse 9 | import io.ktor.client.request.* 10 | import io.ktor.http.* 11 | import kotlinx.coroutines.flow.Flow 12 | 13 | /** 14 | * Default implementation of Ollama generate 15 | */ 16 | class DefaultOllamaGenerateApi( 17 | private val requester: HttpRequester 18 | ) : OllamaGenerate { 19 | override fun stream(request: OllamaGenerateRequest): Flow { 20 | return requester.streamRequest { 21 | method = HttpMethod.Post 22 | url(path = GENERATE_API_PATH) 23 | setBody(request.copy(stream = true)) 24 | contentType(ContentType.Application.Json) 25 | accept(ContentType.Text.EventStream) 26 | headers { 27 | append(HttpHeaders.CacheControl, "no-cache") 28 | append(HttpHeaders.Connection, "keep-alive") 29 | } 30 | } 31 | } 32 | 33 | override suspend fun request(request: OllamaGenerateRequest): OllamaGenerateResponse { 34 | return requester.performRequest { 35 | method = HttpMethod.Post 36 | url(path = GENERATE_API_PATH) 37 | setBody(request) 38 | contentType(ContentType.Application.Json) 39 | } 40 | } 41 | 42 | companion object { 43 | const val GENERATE_API_PATH = "/api/generate" 44 | } 45 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/internal/OllamaApi.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.internal 2 | 3 | import com.tddworks.di.getInstance 4 | import com.tddworks.ollama.api.Ollama 5 | import com.tddworks.ollama.api.OllamaConfig 6 | import com.tddworks.ollama.api.chat.OllamaChat 7 | import com.tddworks.ollama.api.generate.OllamaGenerate 8 | 9 | class OllamaApi( 10 | private val config: OllamaConfig, 11 | private val ollamaChat: OllamaChat, 12 | private val ollamaGenerate: OllamaGenerate 13 | ) : Ollama, OllamaChat by ollamaChat, OllamaGenerate by ollamaGenerate { 14 | 15 | override fun baseUrl(): String { 16 | return config.baseUrl() 17 | } 18 | 19 | override fun port(): Int { 20 | return config.port() 21 | } 22 | 23 | override fun protocol(): String { 24 | return config.protocol() 25 | } 26 | } 27 | 28 | fun Ollama.Companion.create( 29 | config: OllamaConfig, 30 | ollamaChat: OllamaChat = getInstance(), 31 | ollamaGenerate: OllamaGenerate = getInstance() 32 | ): Ollama { 33 | return OllamaApi( 34 | config = config, 35 | ollamaChat = ollamaChat, 36 | ollamaGenerate = ollamaGenerate 37 | ) 38 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/api/json/JsonLenient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.json 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | 6 | /** 7 | * Represents a JSON object that allows for leniency and ignores unknown keys. 8 | * 9 | * @property isLenient Removes JSON specification restriction (RFC-4627) and makes parser more liberal to the malformed input. In lenient mode quoted boolean literals, and unquoted string literals are allowed. 10 | * Its relaxations can be expanded in the future, so that lenient parser becomes even more permissive to invalid value in the input, replacing them with defaults. 11 | * false by default. 12 | * @property ignoreUnknownKeys Specifies whether encounters of unknown properties in the input JSON should be ignored instead of throwing SerializationException. false by default.. 13 | */ 14 | val JsonLenient = Json { 15 | isLenient = true 16 | ignoreUnknownKeys = true 17 | // https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md#class-discriminator-for-polymorphism 18 | encodeDefaults = true 19 | explicitNulls = false 20 | } 21 | -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/commonMain/kotlin/com/tddworks/ollama/di/Koin.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.di 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.internal.* 5 | import com.tddworks.di.commonModule 6 | import com.tddworks.ollama.api.Ollama 7 | import com.tddworks.ollama.api.OllamaConfig 8 | import com.tddworks.ollama.api.chat.OllamaChat 9 | import com.tddworks.ollama.api.chat.internal.DefaultOllamaChatApi 10 | import com.tddworks.ollama.api.generate.OllamaGenerate 11 | import com.tddworks.ollama.api.generate.internal.DefaultOllamaGenerateApi 12 | import com.tddworks.ollama.api.json.JsonLenient 13 | import kotlinx.serialization.json.Json 14 | import org.koin.core.context.startKoin 15 | import org.koin.core.qualifier.named 16 | import org.koin.dsl.KoinAppDeclaration 17 | import org.koin.dsl.module 18 | 19 | fun initOllama(config: OllamaConfig, appDeclaration: KoinAppDeclaration = {}): Ollama { 20 | return startKoin { 21 | appDeclaration() 22 | modules(commonModule(false) + ollamaModules(config)) 23 | }.koin.get() 24 | } 25 | 26 | fun ollamaModules( 27 | config: OllamaConfig, 28 | ) = module { 29 | 30 | single { 31 | Ollama.create(ollamaConfig = config) 32 | } 33 | 34 | single(named("ollamaJson")) { JsonLenient } 35 | 36 | single(named("ollamaHttpRequester")) { 37 | HttpRequester.default( 38 | createHttpClient( 39 | connectionConfig = UrlBasedConnectionConfig( 40 | baseUrl = config.baseUrl, 41 | ), 42 | features = ClientFeatures(json = get(named("ollamaJson"))) 43 | ) 44 | ) 45 | } 46 | 47 | single { 48 | DefaultOllamaChatApi( 49 | requester = get(named("ollamaHttpRequester")) 50 | ) 51 | } 52 | 53 | single { 54 | DefaultOllamaGenerateApi( 55 | requester = get(named("ollamaHttpRequester")) 56 | ) 57 | } 58 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/jvmTest/kotlin/com/tddworks/ollama/api/JsonUtils.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import kotlinx.serialization.EncodeDefault 4 | import kotlinx.serialization.json.Json 5 | 6 | val prettyJson = Json { // this returns the JsonBuilder 7 | prettyPrint = true 8 | ignoreUnknownKeys = true 9 | // optional: specify indent 10 | prettyPrintIndent = " " 11 | 12 | /** 13 | * When this flag is disabled properties with null values without default are not encoded. 14 | */ 15 | explicitNulls = false 16 | 17 | /** 18 | * Controls whether the target property is serialized when its value is equal to a default value, 19 | * regardless of the format settings. 20 | * Does not affect decoding and deserialization process. 21 | * 22 | * Example of usage: 23 | * ``` 24 | * @Serializable 25 | * data class Foo( 26 | * @EncodeDefault(ALWAYS) val a: Int = 42, 27 | * @EncodeDefault(NEVER) val b: Int = 43, 28 | * val c: Int = 44 29 | * ) 30 | * 31 | * Json { encodeDefaults = false }.encodeToString((Foo()) // {"a": 42} 32 | * Json { encodeDefaults = true }.encodeToString((Foo()) // {"a": 42, "c":44} 33 | * ``` 34 | * 35 | * @see EncodeDefault.Mode.ALWAYS 36 | * @see EncodeDefault.Mode.NEVER 37 | */ 38 | encodeDefaults = true 39 | } 40 | 41 | /** 42 | * Represents a JSON object that allows for leniency and ignores unknown keys. 43 | * 44 | * @property isLenient Removes JSON specification restriction (RFC-4627) and makes parser more liberal to the malformed input. In lenient mode quoted boolean literals, and unquoted string literals are allowed. 45 | * Its relaxations can be expanded in the future, so that lenient parser becomes even more permissive to invalid value in the input, replacing them with defaults. 46 | * false by default. 47 | * @property ignoreUnknownKeys Specifies whether encounters of unknown properties in the input JSON should be ignored instead of throwing SerializationException. false by default.. 48 | */ 49 | internal val JsonLenient = Json { 50 | isLenient = true 51 | ignoreUnknownKeys = true 52 | // https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md#class-discriminator-for-polymorphism 53 | encodeDefaults = true 54 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/jvmTest/kotlin/com/tddworks/ollama/api/MockHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | 4 | import com.tddworks.common.network.api.ktor.internal.JsonLenient 5 | import io.ktor.client.* 6 | import io.ktor.client.engine.mock.* 7 | import io.ktor.client.plugins.* 8 | import io.ktor.client.plugins.contentnegotiation.* 9 | import io.ktor.client.request.* 10 | import io.ktor.http.* 11 | import io.ktor.serialization.kotlinx.* 12 | 13 | /** 14 | * See https://ktor.io/docs/http-client-testing.html#usage 15 | */ 16 | fun mockHttpClient(mockResponse: String) = HttpClient(MockEngine) { 17 | 18 | val headers = 19 | headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) 20 | 21 | install(ContentNegotiation) { 22 | register(ContentType.Application.Json, KotlinxSerializationConverter(JsonLenient)) 23 | } 24 | 25 | engine { 26 | addHandler { request -> 27 | if (request.url.encodedPath == "/api/chat" || request.url.encodedPath == "/api/generate") { 28 | respond(mockResponse, HttpStatusCode.OK, headers) 29 | } else { 30 | error("Unhandled ${request.url.encodedPath}") 31 | } 32 | } 33 | } 34 | 35 | defaultRequest { 36 | url { 37 | protocol = URLProtocol.HTTPS 38 | host = "api.lemonsqueezy.com" 39 | } 40 | 41 | header(HttpHeaders.ContentType, ContentType.Application.Json) 42 | contentType(ContentType.Application.Json) 43 | } 44 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/jvmTest/kotlin/com/tddworks/ollama/api/OllamaConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import org.junit.jupiter.api.Assertions.assertEquals 4 | import org.junit.jupiter.api.Test 5 | 6 | 7 | class OllamaConfigTest { 8 | 9 | @Test 10 | fun `should return overridden settings`() { 11 | val target = OllamaConfig( 12 | baseUrl = { "some-url" }, 13 | port = { 8080 }, 14 | protocol = { "https" } 15 | ) 16 | 17 | assertEquals("some-url", target.baseUrl()) 18 | 19 | assertEquals(8080, target.port()) 20 | 21 | assertEquals("https", target.protocol()) 22 | } 23 | 24 | @Test 25 | fun `should return default settings`() { 26 | val target = OllamaConfig() 27 | 28 | assertEquals("localhost", target.baseUrl()) 29 | 30 | assertEquals(11434, target.port()) 31 | 32 | assertEquals("http", target.protocol()) 33 | } 34 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/jvmTest/kotlin/com/tddworks/ollama/api/OllamaModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | class OllamaModelTest { 7 | 8 | @Test 9 | fun `should return correct latest API model name`() { 10 | assertEquals("deepseek-coder:6.7b-base", OllamaModel.DEEPSEEK_CODER.value) 11 | assertEquals("llama3", OllamaModel.LLAMA3.value) 12 | assertEquals("llama2", OllamaModel.LLAMA2.value) 13 | assertEquals("codellama", OllamaModel.CODE_LLAMA.value) 14 | assertEquals("mistral", OllamaModel.MISTRAL.value) 15 | } 16 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/jvmTest/kotlin/com/tddworks/ollama/api/OllamaTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import com.tddworks.di.getInstance 4 | import com.tddworks.ollama.di.initOllama 5 | import org.junit.jupiter.api.Assertions.assertEquals 6 | import org.junit.jupiter.api.BeforeEach 7 | import org.junit.jupiter.api.Test 8 | import org.koin.dsl.koinApplication 9 | import org.koin.test.check.checkModules 10 | import org.koin.test.junit5.AutoCloseKoinTest 11 | 12 | class OllamaTestTest : AutoCloseKoinTest() { 13 | 14 | @BeforeEach 15 | fun setUp() { 16 | koinApplication { 17 | initOllama( 18 | config = OllamaConfig( 19 | baseUrl = { "127.0.0.1" }, 20 | port = { 8080 }, 21 | protocol = { "https" } 22 | ) 23 | ) 24 | }.checkModules() 25 | } 26 | 27 | @Test 28 | fun `should return overridden settings`() { 29 | val target = getInstance() 30 | 31 | assertEquals("127.0.0.1", target.baseUrl()) 32 | 33 | assertEquals(8080, target.port()) 34 | 35 | assertEquals("https", target.protocol()) 36 | } 37 | 38 | @Test 39 | fun `should return default settings`() { 40 | val target = Ollama.create(ollamaConfig = OllamaConfig()) 41 | 42 | assertEquals("localhost", target.baseUrl()) 43 | 44 | assertEquals(11434, target.port()) 45 | 46 | assertEquals("http", target.protocol()) 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/jvmTest/kotlin/com/tddworks/ollama/api/TestKoinCoroutineExtension.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.test.StandardTestDispatcher 5 | import kotlinx.coroutines.test.TestDispatcher 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.setMain 8 | import org.junit.jupiter.api.extension.AfterEachCallback 9 | import org.junit.jupiter.api.extension.BeforeEachCallback 10 | import org.junit.jupiter.api.extension.ExtensionContext 11 | 12 | class TestKoinCoroutineExtension(private val testDispatcher: TestDispatcher = StandardTestDispatcher()) : 13 | BeforeEachCallback, AfterEachCallback { 14 | override fun beforeEach(context: ExtensionContext?) { 15 | Dispatchers.setMain(testDispatcher) 16 | } 17 | 18 | override fun afterEach(context: ExtensionContext?) { 19 | Dispatchers.resetMain() 20 | } 21 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/jvmTest/kotlin/com/tddworks/ollama/api/chat/OllamaChatRequestTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.chat 2 | 3 | import com.tddworks.ollama.api.json.JsonLenient 4 | import org.junit.jupiter.api.Test 5 | import kotlin.test.assertEquals 6 | 7 | class OllamaChatRequestTest { 8 | 9 | @Test 10 | fun `should convert json to object`() { 11 | // given 12 | val json = """ 13 | { 14 | "model": "llama3", 15 | "messages": [{ 16 | "role": "user", 17 | "content": "Why is the sky blue?" 18 | }], 19 | "format": "json", 20 | "keep_alive": "5m", 21 | "stream": false, 22 | "options": { 23 | "num_predict": 100, 24 | "temperature": 0.8, 25 | "stop": ["\n", "user:"] 26 | } 27 | } 28 | """.trimIndent() 29 | 30 | // when 31 | val request = JsonLenient.decodeFromString(OllamaChatRequest.serializer(), json) 32 | 33 | // then 34 | assertEquals("llama3", request.model) 35 | assertEquals(1, request.messages.size) 36 | assertEquals("user", request.messages[0].role) 37 | assertEquals("Why is the sky blue?", request.messages[0].content) 38 | assertEquals("json", request.format) 39 | assertEquals("5m", request.keepAlive) 40 | 41 | assertEquals(100, request.options?.get("num_predict")) 42 | assertEquals(0.8, request.options?.get("temperature")) 43 | assertEquals(listOf("\n", "user:"), request.options?.get("stop")) 44 | } 45 | 46 | @Test 47 | fun `should return dummy request`() { 48 | // given 49 | val request = OllamaChatRequest.dummy() 50 | 51 | // then 52 | assertEquals("llama2", request.model) 53 | assertEquals(1, request.messages.size) 54 | assertEquals("user", request.messages[0].role) 55 | assertEquals("Hello!", request.messages[0].content) 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-core/src/jvmTest/kotlin/com/tddworks/ollama/api/chat/internal/DefaultOllamaChatITest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api.chat.internal 2 | 3 | import com.tddworks.di.getInstance 4 | import com.tddworks.ollama.api.Ollama 5 | import com.tddworks.ollama.api.OllamaConfig 6 | import com.tddworks.ollama.api.chat.OllamaChatMessage 7 | import com.tddworks.ollama.api.chat.OllamaChatRequest 8 | import com.tddworks.ollama.di.initOllama 9 | import kotlinx.coroutines.test.runTest 10 | import org.junit.jupiter.api.Assertions.assertEquals 11 | import org.junit.jupiter.api.Assertions.assertNotNull 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.junit.jupiter.api.Disabled 14 | import org.junit.jupiter.api.Test 15 | import org.koin.test.junit5.AutoCloseKoinTest 16 | 17 | @Disabled 18 | class DefaultOllamaChatITest : AutoCloseKoinTest() { 19 | 20 | @BeforeEach 21 | fun setUp() { 22 | initOllama( 23 | config = OllamaConfig(baseUrl = { "http://localhost:11434" }) 24 | ) 25 | } 26 | 27 | 28 | @Test 29 | fun `should return correct base url`() { 30 | assertEquals("localhost", Ollama.BASE_URL) 31 | } 32 | 33 | 34 | @Test 35 | fun `should return stream response`() = runTest { 36 | val ollama = getInstance() 37 | 38 | ollama.stream( 39 | OllamaChatRequest( 40 | model = "llama2", 41 | messages = listOf( 42 | OllamaChatMessage( 43 | role = "user", 44 | content = "hello" 45 | ) 46 | ) 47 | ) 48 | ).collect { 49 | println("stream response: $it") 50 | } 51 | } 52 | 53 | @Test 54 | fun `should return create response`() = runTest { 55 | val ollama = getInstance() 56 | 57 | val r = ollama.request( 58 | OllamaChatRequest( 59 | model = "llama2", 60 | messages = listOf( 61 | OllamaChatMessage( 62 | role = "user", 63 | content = "hello" 64 | ) 65 | ) 66 | ) 67 | ) 68 | 69 | assertNotNull(r.message?.content) 70 | } 71 | } -------------------------------------------------------------------------------- /ollama-client/ollama-client-darwin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tddworks/openai-kotlin/02e262493ef8b80ee89dd6ca8e705efd136fef13/ollama-client/ollama-client-darwin/.gitkeep -------------------------------------------------------------------------------- /ollama-client/ollama-client-darwin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.touchlab.kmmbridge) 4 | alias(libs.plugins.touchlab.skie) 5 | `maven-publish` 6 | } 7 | 8 | kotlin { 9 | listOf( 10 | macosArm64(), 11 | iosArm64(), 12 | iosSimulatorArm64() 13 | ).forEach { macosTarget -> 14 | macosTarget.binaries.framework { 15 | baseName = "ollama-client-darwin" 16 | export(projects.ollamaClient.ollamaClientCore) 17 | isStatic = true 18 | } 19 | } 20 | 21 | sourceSets { 22 | commonMain { 23 | dependencies { 24 | api(projects.ollamaClient.ollamaClientCore) 25 | implementation(libs.ktor.client.darwin) 26 | } 27 | } 28 | appleMain {} 29 | } 30 | } 31 | 32 | addGithubPackagesRepository() // <- Add the GitHub Packages repo 33 | 34 | kmmbridge { 35 | /** 36 | * reference: https://kmmbridge.touchlab.co/docs/artifacts/MAVEN_REPO_ARTIFACTS#github-packages 37 | * In kmmbridge, notice mavenPublishArtifacts() tells the plugin to push KMMBridge artifacts to a Maven repo. You then need to define a repo. Rather than do everything manually, you can just call addGithubPackagesRepository(), which will add the correct repo given parameters that are passed in from GitHub Actions. 38 | */ 39 | mavenPublishArtifacts() // <- Publish using a Maven repo 40 | // spm(swiftToolVersion = "5.9") 41 | // spm { 42 | // swiftToolsVersion = "5.9" 43 | // platforms { 44 | // iOS("14") 45 | // macOS("13") 46 | // watchOS("7") 47 | // tvOS("14") 48 | // } 49 | // } 50 | } 51 | -------------------------------------------------------------------------------- /ollama-client/ollama-client-darwin/src/appleMain/kotlin/com/tddworks/ollama/api/DarwinOllama.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.ollama.api 2 | 3 | import com.tddworks.ollama.di.initOllama 4 | 5 | 6 | /** 7 | * Creates an instance of Ollama with the specified configuration options. 8 | * 9 | * @param port a function that returns the port number for the Ollama instance, defaults to Ollama.PORT 10 | * @param protocol a function that returns the protocol for the Ollama instance, defaults to Ollama.PROTOCOL 11 | * @param baseUrl a function that returns the base URL for the Ollama instance, defaults to Ollama.BASE_URL 12 | * @return an Ollama instance initialized with the provided configuration 13 | */ 14 | object DarwinOllama { 15 | 16 | /** 17 | * Function to create an Ollama instance with the provided configuration. 18 | * 19 | * @param port function returning an integer representing the port, defaults to Ollama.PORT 20 | * @param protocol function returning a string representing the protocol, defaults to Ollama.PROTOCOL 21 | * @param baseUrl function returning a string representing the base URL, defaults to Ollama.BASE_URL 22 | * @return an Ollama instance created with the provided configuration 23 | */ 24 | fun ollama( 25 | baseUrl: () -> String = { Ollama.BASE_URL }, 26 | ): Ollama = 27 | initOllama(OllamaConfig(baseUrl = baseUrl)) 28 | } -------------------------------------------------------------------------------- /openai-client/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `maven-publish` 3 | } 4 | 5 | kotlin { 6 | jvm() 7 | sourceSets { 8 | commonMain { 9 | dependencies { 10 | api(projects.openaiClient.openaiClientCore) 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /openai-client/jvm/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tddworks/openai-kotlin/02e262493ef8b80ee89dd6ca8e705efd136fef13/openai-client/jvm/.gitkeep -------------------------------------------------------------------------------- /openai-client/openai-client-cio/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | // alias(libs.plugins.touchlab.kmmbridge) 4 | // id("module.publication") 5 | `maven-publish` 6 | } 7 | kotlin { 8 | jvm() 9 | sourceSets { 10 | jvmMain { 11 | dependencies { 12 | api(projects.openaiClient.openaiClientCore) 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /openai-client/openai-client-cio/src/jvmMain/kotlin/Stub.kt: -------------------------------------------------------------------------------- 1 | internal fun publicationStub() = Unit 2 | -------------------------------------------------------------------------------- /openai-client/openai-client-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.kover) 4 | `maven-publish` 5 | } 6 | 7 | kotlin { 8 | jvm() 9 | macosArm64() 10 | iosArm64() 11 | iosSimulatorArm64() 12 | sourceSets { 13 | commonMain.dependencies { 14 | // put your Multiplatform dependencies here 15 | api(projects.common) 16 | } 17 | 18 | commonTest.dependencies { 19 | implementation(libs.ktor.client.mock) 20 | } 21 | 22 | macosMain.dependencies { 23 | api(libs.ktor.client.darwin) 24 | } 25 | 26 | jvmMain.dependencies { 27 | api(libs.ktor.client.cio) 28 | } 29 | 30 | jvmTest.dependencies { 31 | implementation(project.dependencies.platform(libs.junit.bom)) 32 | implementation(libs.bundles.jvm.test) 33 | implementation(libs.kotlinx.coroutines.test) 34 | implementation(libs.koin.test) 35 | implementation(libs.koin.test.junit5) 36 | implementation(libs.app.cash.turbine) 37 | implementation("com.tngtech.archunit:archunit-junit5:1.1.0") 38 | implementation("org.reflections:reflections:0.10.2") 39 | } 40 | } 41 | } 42 | 43 | tasks { 44 | named("jvmTest") { 45 | useJUnitPlatform() 46 | } 47 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/OpenAI.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.internal.* 5 | import com.tddworks.di.createJson 6 | import com.tddworks.di.getInstance 7 | import com.tddworks.openai.api.chat.api.Chat 8 | import com.tddworks.openai.api.chat.internal.default 9 | import com.tddworks.openai.api.images.api.Images 10 | import com.tddworks.openai.api.images.internal.default 11 | import com.tddworks.openai.api.legacy.completions.api.Completions 12 | import com.tddworks.openai.api.legacy.completions.api.internal.default 13 | 14 | interface OpenAI : Chat, Images, Completions { 15 | companion object { 16 | const val BASE_URL = "https://api.openai.com" 17 | 18 | fun default(config: OpenAIConfig): OpenAI { 19 | val requester = HttpRequester.default( 20 | createHttpClient( 21 | connectionConfig = UrlBasedConnectionConfig(config.baseUrl), 22 | authConfig = AuthConfig(config.apiKey), 23 | features = ClientFeatures(json = createJson()) 24 | ) 25 | ) 26 | return default(requester) 27 | } 28 | 29 | fun default( 30 | requester: HttpRequester, 31 | chatCompletionPath: String = Chat.CHAT_COMPLETIONS_PATH 32 | ): OpenAI { 33 | val chatApi = Chat.default( 34 | requester = requester, 35 | chatCompletionPath = chatCompletionPath 36 | ) 37 | 38 | val imagesApi = Images.default( 39 | requester = requester 40 | ) 41 | 42 | val completionsApi = Completions.default( 43 | requester = requester 44 | ) 45 | 46 | return object : OpenAI, Chat by chatApi, Images by imagesApi, 47 | Completions by completionsApi {} 48 | } 49 | } 50 | } 51 | 52 | class OpenAIApi : OpenAI, Chat by getInstance(), Images by getInstance(), 53 | Completions by getInstance() -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/OpenAIConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api 2 | 3 | import org.koin.core.component.KoinComponent 4 | 5 | data class OpenAIConfig( 6 | val apiKey: () -> String = { "CONFIG_API_KEY" }, 7 | val baseUrl: () -> String = { OpenAI.BASE_URL }, 8 | ) : KoinComponent -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/chat/api/Chat.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.serialization.ExperimentalSerializationApi 5 | 6 | /** 7 | * Chat API - https://platform.openai.com/docs/api-reference/chat 8 | * Given a list of messages comprising a conversation, the model will return a response. 9 | * Related guide: Chat Completions 10 | */ 11 | @OptIn(ExperimentalSerializationApi::class) 12 | interface Chat { 13 | /** 14 | * Create a chat completion. 15 | * @param request The request to create a chat completion. 16 | * @return The chat completion. 17 | */ 18 | suspend fun chatCompletions(request: ChatCompletionRequest): ChatCompletion 19 | 20 | 21 | /** 22 | * Stream a chat completion. 23 | * @param request The request to stream a chat completion. 24 | * @return The chat completion chunks as a stream 25 | */ 26 | fun streamChatCompletions(request: ChatCompletionRequest): Flow 27 | 28 | companion object { 29 | const val CHAT_COMPLETIONS_PATH = "/v1/chat/completions" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/chat/api/ChatCompletion.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ChatCompletion(val id: String, val created: Long, val model: String, val choices: List) { 7 | companion object { 8 | fun dummy() = ChatCompletion( 9 | "chatcmpl-8Zu4AF8QMK3zFgdzXIPjFS4VkWErX", 10 | 1634160000, 11 | "gpt-3.5-turbo", 12 | listOf(ChatChoice.dummy()) 13 | ) 14 | } 15 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/chat/api/ChatCompletionChunk.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Data class representing a chat completion. 8 | * 9 | * @property id The ID of the chat completion. 10 | * @property `object` The type of object returned (always "text"). 11 | * @property created The timestamp of when the completion was created. 12 | * @property model The name of the GPT model used to generate the completion. 13 | * @property choices A list of possible chat completion options. 14 | */ 15 | @Serializable 16 | data class ChatCompletionChunk( 17 | val id: String, 18 | val `object`: String, 19 | val created: Long, 20 | val model: String, 21 | @SerialName("system_fingerprint") 22 | val systemFingerprint: String? = null, 23 | val choices: List, 24 | ) { 25 | companion object { 26 | fun dummy() = ChatCompletionChunk( 27 | id = "fake-id", 28 | `object` = "text", 29 | created = 0, 30 | model = "fake-model", 31 | choices = listOf(ChatChunk.fake()), 32 | ) 33 | } 34 | 35 | fun content() = choices.joinToString(separator = "") { it.delta.content ?: "" } 36 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/chat/api/Role.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | /** 7 | * Representing the available message sender roles. 8 | * deprecated or obsolete role like `Function` is removed. 9 | */ 10 | @JvmInline 11 | @Serializable 12 | value class Role(val name: String) { 13 | companion object { 14 | val System: Role = Role("system") 15 | val User: Role = Role("user") 16 | val Assistant: Role = Role("assistant") 17 | val Tool: Role = Role("tool") 18 | } 19 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/chat/api/Settings.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | //import com.snacks.openai.api.OpenAISettings 4 | // 5 | // 6 | //interface Settings { 7 | // fun settings(): OpenAISettings 8 | //} 9 | -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/chat/api/vision/VisionMessageContentSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api.vision 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.json.JsonContentPolymorphicSerializer 5 | import kotlinx.serialization.json.JsonElement 6 | import kotlinx.serialization.json.jsonObject 7 | import kotlinx.serialization.json.jsonPrimitive 8 | 9 | object VisionMessageContentSerializer : 10 | JsonContentPolymorphicSerializer(VisionMessageContent::class) { 11 | override fun selectDeserializer(element: JsonElement): KSerializer { 12 | val typeObject = element.jsonObject["type"] 13 | val jsonPrimitive = typeObject?.jsonPrimitive 14 | val type = jsonPrimitive?.content 15 | 16 | return when (type) { 17 | ContentType.TEXT.value -> VisionMessageContent.TextContent.serializer() 18 | ContentType.IMAGE.value -> VisionMessageContent.ImageContent.serializer() 19 | else -> throw IllegalArgumentException("Unknown type") 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/images/api/Image.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.api 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * Represents the url or the content of an image generated by the OpenAI API. 8 | * @see [Image](https://beta.openai.com/docs/api-reference/images/create) 9 | */ 10 | @Serializable 11 | data class Image( 12 | 13 | /** 14 | * The URL of the generated image, if response_format is url (default). 15 | */ 16 | @SerialName("url") 17 | val url: String? = null, 18 | 19 | /** 20 | * The base64-encoded JSON of the generated image, if response_format is b64_json. 21 | */ 22 | @SerialName("b64_json") 23 | val b64JSON: String? = null, 24 | 25 | /** 26 | *The prompt that was used to generate the image, if there was any revision to the prompt. 27 | */ 28 | @SerialName("revised_prompt") 29 | val revisedPrompt: String? = null, 30 | ) 31 | -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/images/api/Images.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.api 2 | 3 | import com.tddworks.common.network.api.ktor.api.ListResponse 4 | 5 | 6 | /** 7 | * Given a prompt and/or an input image, the model will generate a new image. 8 | * @see [Images API](https://platform.openai.com/docs/api-reference/images) 9 | */ 10 | interface Images { 11 | 12 | /** 13 | * Creates an image given a prompt. 14 | * Get images as URLs or base64-encoded JSON. 15 | * @param request image creation request. 16 | * @return list of images. 17 | */ 18 | suspend fun generate(request: ImageCreate): ListResponse 19 | 20 | companion object { 21 | const val IMAGES_GENERATIONS_PATH = "/v1/images/generations" 22 | } 23 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/images/api/Quality.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | /** 7 | * The quality of the image that will be generated. 8 | * hd creates images with finer details and greater consistency across the image. 9 | * This param is only supported for dall-e-3. 10 | * Defaults to standard 11 | */ 12 | @Serializable 13 | @JvmInline 14 | value class Quality(val value: String) { 15 | companion object { 16 | val hd = Quality("hd") 17 | val standard = Quality("standard") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/images/api/ResponseFormat.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | @Serializable 7 | @JvmInline 8 | value class ResponseFormat(val value: String) { 9 | companion object { 10 | val base64 = ResponseFormat("b64_json") 11 | val url = ResponseFormat("url") 12 | } 13 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/images/api/Size.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | /** 7 | * The size of the generated images. 8 | * Must be one of 9 | * 256x256, 10 | * 512x512, or 11 | * 1024x1024 for dall-e-2. 12 | * Must be one of 13 | * 1024x1024, 14 | * 1792x1024, or 15 | * 1024x1792 for dall-e-3 models. 16 | */ 17 | @JvmInline 18 | @Serializable 19 | value class Size(val value: String) { 20 | 21 | companion object { 22 | val size256x256: Size = Size("256x256") 23 | val size512x512: Size = Size("512x512") 24 | val size1024x1024: Size = Size("1024x1024") 25 | val size1792x1024: Size = Size("1792x1024") 26 | val size1024x1792: Size = Size("1024x1792") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/images/api/Style.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | @Serializable 7 | @JvmInline 8 | /** 9 | * The style of the generated images. 10 | * Must be one of vivid or natural. 11 | * Vivid causes the model to lean towards generating hyper-real and dramatic images. 12 | * Natural causes the model to produce more natural, less hyper-real looking images. 13 | * This param is only supported for dall-e-3. 14 | */ 15 | value class Style(val value: String) { 16 | companion object { 17 | val vivid = Style("vivid") 18 | val natural = Style("natural") 19 | } 20 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/images/internal/DefaultImagesApi.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.internal 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.api.ListResponse 5 | import com.tddworks.common.network.api.ktor.api.performRequest 6 | import com.tddworks.openai.api.images.api.Image 7 | import com.tddworks.openai.api.images.api.ImageCreate 8 | import com.tddworks.openai.api.images.api.Images 9 | import io.ktor.client.request.* 10 | import io.ktor.http.* 11 | import kotlinx.serialization.ExperimentalSerializationApi 12 | 13 | internal class DefaultImagesApi(private val requester: HttpRequester) : Images { 14 | @OptIn(ExperimentalSerializationApi::class) 15 | override suspend fun generate(request: ImageCreate): ListResponse { 16 | return requester.performRequest> { 17 | method = HttpMethod.Post 18 | url(path = Images.IMAGES_GENERATIONS_PATH) 19 | setBody(request) 20 | contentType(ContentType.Application.Json) 21 | } 22 | } 23 | } 24 | 25 | fun Images.Companion.default(requester: HttpRequester): Images = 26 | DefaultImagesApi(requester) -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/legacy/completions/api/Completion.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.legacy.completions.api 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | /** 7 | * https://platform.openai.com/docs/api-reference/completions/object 8 | */ 9 | @Serializable 10 | data class Completion( 11 | /** 12 | * A unique identifier for the completion. 13 | */ 14 | val id: String, 15 | /** 16 | * The list of completion choices the model generated for the input prompt. 17 | */ 18 | val choices: List, 19 | /** 20 | * The Unix timestamp (in seconds) of when the completion was created. 21 | */ 22 | val created: Int, 23 | /** 24 | * The model used for completion. 25 | */ 26 | val model: String, 27 | /** 28 | * This fingerprint represents the backend configuration that the model runs with. 29 | * 30 | * Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism. 31 | */ 32 | @SerialName("system_fingerprint") 33 | val systemFingerprint: String? = null, 34 | /** 35 | * The object type, which is always "text_completion" 36 | */ 37 | val `object`: String? = null, 38 | /** 39 | * Usage statistics for the completion request. 40 | */ 41 | val usage: Usage? = null 42 | ) { 43 | companion object { 44 | fun dummy() = Completion( 45 | id = "id", 46 | choices = emptyList(), 47 | created = 0, 48 | model = "model", 49 | systemFingerprint = "systemFingerprint", 50 | `object` = "object", 51 | usage = Usage() 52 | ) 53 | } 54 | } 55 | 56 | @Serializable 57 | data class CompletionChoice( 58 | @SerialName("text") 59 | val text: String, 60 | @SerialName("index") 61 | val index: Int, 62 | @SerialName("logprobs") 63 | val logprobs: Map? = null, 64 | @SerialName("finish_reason") 65 | val finishReason: String = "" 66 | ) 67 | 68 | @Serializable 69 | data class Usage( 70 | @SerialName("prompt_tokens") 71 | val promptTokens: Int? = null, 72 | @SerialName("completion_tokens") 73 | val completionTokens: Int? = null, 74 | @SerialName("total_tokens") 75 | val totalTokens: Int? = null, 76 | ) 77 | -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/legacy/completions/api/Completions.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.legacy.completions.api 2 | 3 | /** 4 | * https://platform.openai.com/docs/api-reference/completions 5 | * Given a prompt, the model will return one or more predicted completions along with the probabilities of alternative tokens at each position. Most developer should use our Chat Completions API to leverage our best and newest models. 6 | */ 7 | interface Completions { 8 | suspend fun completions(request: CompletionRequest): Completion 9 | companion object 10 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/api/legacy/completions/api/internal/DefaultCompletionsApi.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.legacy.completions.api.internal 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.api.performRequest 5 | import com.tddworks.openai.api.legacy.completions.api.Completion 6 | import com.tddworks.openai.api.legacy.completions.api.CompletionRequest 7 | import com.tddworks.openai.api.legacy.completions.api.Completions 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | 11 | internal class DefaultCompletionsApi( 12 | private val requester: HttpRequester, 13 | ) : Completions { 14 | override suspend fun completions(request: CompletionRequest): Completion { 15 | return requester.performRequest { 16 | method = HttpMethod.Post 17 | url(path = COMPLETIONS_PATH) 18 | setBody(request) 19 | contentType(ContentType.Application.Json) 20 | } 21 | } 22 | 23 | companion object { 24 | const val COMPLETIONS_PATH = "/v1/completions" 25 | } 26 | } 27 | 28 | fun Completions.Companion.default(requester: HttpRequester): Completions = 29 | DefaultCompletionsApi(requester) -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/commonMain/kotlin/com/tddworks/openai/di/Koin.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.di 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.common.network.api.ktor.internal.* 5 | import com.tddworks.di.commonModule 6 | import com.tddworks.openai.api.OpenAI 7 | import com.tddworks.openai.api.OpenAIApi 8 | import com.tddworks.openai.api.OpenAIConfig 9 | import com.tddworks.openai.api.chat.api.Chat 10 | import com.tddworks.openai.api.chat.internal.DefaultChatApi 11 | import com.tddworks.openai.api.images.api.Images 12 | import com.tddworks.openai.api.images.internal.DefaultImagesApi 13 | import com.tddworks.openai.api.legacy.completions.api.Completions 14 | import com.tddworks.openai.api.legacy.completions.api.internal.DefaultCompletionsApi 15 | import org.koin.core.context.startKoin 16 | import org.koin.core.qualifier.named 17 | import org.koin.dsl.KoinAppDeclaration 18 | import org.koin.dsl.module 19 | 20 | 21 | fun initOpenAI(config: OpenAIConfig, appDeclaration: KoinAppDeclaration = {}): OpenAI { 22 | return startKoin { 23 | appDeclaration() 24 | modules(commonModule(false) + openAIModules(config)) 25 | }.koin.get() 26 | } 27 | 28 | fun openAIModules( 29 | config: OpenAIConfig, 30 | ) = module { 31 | 32 | single { 33 | OpenAIApi() 34 | } 35 | 36 | single(named("openAIHttpRequester")) { 37 | HttpRequester.default( 38 | createHttpClient( 39 | connectionConfig = UrlBasedConnectionConfig(config.baseUrl), 40 | authConfig = AuthConfig(config.apiKey), 41 | // get from commonModule 42 | features = ClientFeatures(json = get()) 43 | ) 44 | ) 45 | } 46 | 47 | single { 48 | DefaultChatApi( 49 | requester = get(named("openAIHttpRequester")) 50 | ) 51 | } 52 | 53 | single { 54 | DefaultImagesApi( 55 | requester = get(named("openAIHttpRequester")) 56 | ) 57 | } 58 | 59 | single { 60 | DefaultCompletionsApi( 61 | requester = get(named("openAIHttpRequester")) 62 | ) 63 | } 64 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmMain/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tddworks/openai-kotlin/02e262493ef8b80ee89dd6ca8e705efd136fef13/openai-client/openai-client-core/src/jvmMain/.gitkeep -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/common/network/api/ktor/internal/HostPortConnectionConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | class HostPortConnectionConfigTest { 7 | 8 | @Test 9 | fun `should return default protocol and port`() { 10 | val config = HostPortConnectionConfig { "example.com" } 11 | 12 | assertNull(config.protocol(), "Protocol should default to null") 13 | 14 | assertNull(config.port(), "Port should default to null") 15 | } 16 | 17 | @Test 18 | fun `should able to specified protocol and port`() { 19 | val protocol: () -> String? = { "http" } 20 | val port: () -> Int? = { 8080 } 21 | val config = HostPortConnectionConfig( 22 | protocol = protocol, 23 | port = port, 24 | host = { "example.com" } 25 | ) 26 | 27 | assertEquals("http", config.protocol(), "Protocol should be 'http'") 28 | assertEquals(8080, config.port(), "Port should be 8080") 29 | } 30 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/common/network/api/ktor/internal/exception/OpenAIErrorTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.common.network.api.ktor.internal.exception 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | 7 | class OpenAIErrorTest { 8 | 9 | @Test 10 | fun `should convert OpenAIError to OpenAIErrorDetails`(){ 11 | val openAIError = OpenAIError(OpenAIErrorDetails("code", "message", "param", "type")) 12 | val openAIErrorDetails = openAIError.detail 13 | assertEquals("code", openAIErrorDetails?.code) 14 | assertEquals("message", openAIErrorDetails?.message) 15 | assertEquals("param", openAIErrorDetails?.param) 16 | assertEquals("type", openAIErrorDetails?.type) 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/InternalPackageTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api 2 | 3 | import com.tngtech.archunit.core.importer.ClassFileImporter 4 | import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.jupiter.api.Test 7 | import org.reflections.Reflections 8 | import org.reflections.scanners.Scanners 9 | import java.io.IOException 10 | 11 | class InternalPackageTest { 12 | 13 | companion object { 14 | private val BASE_PACKAGE = InternalPackageTest::class.java.`package`.name 15 | } 16 | 17 | private val analyzedClasses = ClassFileImporter().importPackages(BASE_PACKAGE) 18 | 19 | @Test 20 | @Throws(IOException::class) 21 | fun internalPackagesAreNotAccessedFromOutside() { 22 | 23 | // so that the test will break when the base package is re-named 24 | assertPackageExists(BASE_PACKAGE) 25 | val internalPackages = internalPackages(BASE_PACKAGE) 26 | for (internalPackage in internalPackages) { 27 | assertPackageExists(internalPackage) 28 | assertPackageIsNotAccessedFromOutside(internalPackage) 29 | } 30 | } 31 | 32 | /** 33 | * Finds all packages named "internal". 34 | */ 35 | private fun internalPackages(basePackage: String): List { 36 | val scanner = Scanners.SubTypes 37 | val reflections = Reflections(basePackage, scanner) 38 | return reflections.getSubTypesOf(Object::class.java).map { 39 | it.`package`.name 40 | }.filter { 41 | it.endsWith(".internal") 42 | } 43 | } 44 | 45 | private fun assertPackageIsNotAccessedFromOutside(internalPackage: String) { 46 | noClasses() 47 | .that() 48 | .resideOutsideOfPackage(packageMatcher(internalPackage)) 49 | .should() 50 | .dependOnClassesThat() 51 | .resideInAPackage(packageMatcher(internalPackage)) 52 | .check(analyzedClasses) 53 | } 54 | 55 | private fun assertPackageExists(packageName: String?) { 56 | assertThat(analyzedClasses.containPackage(packageName)) 57 | .`as`("package %s exists", packageName) 58 | .isTrue() 59 | } 60 | 61 | private fun packageMatcher(fullyQualifiedPackage: String): String? { 62 | return "$fullyQualifiedPackage.." 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/OpenAIITest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api 2 | 3 | import app.cash.turbine.test 4 | import com.tddworks.openai.api.chat.api.ChatCompletionRequest 5 | import com.tddworks.openai.api.chat.api.ChatMessage 6 | import com.tddworks.openai.api.chat.api.OpenAIModel 7 | import com.tddworks.openai.di.initOpenAI 8 | import kotlinx.coroutines.test.runTest 9 | import kotlinx.serialization.ExperimentalSerializationApi 10 | import org.junit.jupiter.api.Assertions.assertNotNull 11 | import org.junit.jupiter.api.BeforeEach 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable 14 | import org.koin.test.junit5.AutoCloseKoinTest 15 | import kotlin.time.Duration.Companion.seconds 16 | 17 | @ExperimentalSerializationApi 18 | @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") 19 | class OpenAIITest : AutoCloseKoinTest() { 20 | private lateinit var openAI: OpenAI 21 | 22 | @BeforeEach 23 | fun setUp() { 24 | openAI = initOpenAI(OpenAIConfig( 25 | baseUrl = { OpenAI.BASE_URL }, 26 | apiKey = { System.getenv("OPENAI_API_KEY") ?: "CONFIGURE_ME" } 27 | )) 28 | } 29 | 30 | 31 | @Test 32 | fun `should use openai client to get stream chat completions`() = runTest { 33 | openAI.streamChatCompletions( 34 | ChatCompletionRequest( 35 | messages = listOf(ChatMessage.UserMessage("hello")), 36 | maxTokens = 1024, 37 | model = OpenAIModel.GPT_3_5_TURBO 38 | ) 39 | ).test(timeout = 10.seconds) { 40 | assertNotNull(awaitItem()) 41 | assertNotNull(awaitItem()) 42 | cancelAndIgnoreRemainingEvents() 43 | } 44 | } 45 | 46 | @Test 47 | fun `should use openai client to get chat completions`() = runTest { 48 | val response = openAI.chatCompletions( 49 | ChatCompletionRequest( 50 | messages = listOf(ChatMessage.UserMessage("hello")), 51 | maxTokens = 1024, 52 | model = OpenAIModel.GPT_3_5_TURBO 53 | ) 54 | ) 55 | assertNotNull(response) 56 | } 57 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/chat/api/ChatChoiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | import com.tddworks.openai.api.common.prettyJson 4 | import org.junit.jupiter.api.Assertions.* 5 | import org.junit.jupiter.api.Test 6 | 7 | class ChatChoiceTest { 8 | 9 | @Test 10 | fun `should return chat choice from json`() { 11 | val json = """ 12 | { 13 | "index": 0, 14 | "message": { 15 | "role": "assistant", 16 | "content": "Hello! How can I assist you today?" 17 | }, 18 | "logprobs": null, 19 | "finish_reason": "stop" 20 | } 21 | """.trimIndent() 22 | 23 | val chatChoice = prettyJson.decodeFromString(ChatChoice.serializer(), json) 24 | 25 | with(chatChoice) { 26 | assertEquals(0, index) 27 | assertEquals("Hello! How can I assist you today?", message.content) 28 | assertEquals("assistant", message.role.name) 29 | assertEquals("stop", finishReason?.value) 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/chat/api/ChatCompletionChunkTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | class ChatCompletionChunkTest { 7 | 8 | @Test 9 | fun `should create dummy chunk`() { 10 | val chunk = ChatCompletionChunk.dummy() 11 | assertEquals("fake-id", chunk.id) 12 | assertEquals("text", chunk.`object`) 13 | assertEquals(0, chunk.created) 14 | assertEquals("fake-model", chunk.model) 15 | assertEquals(1, chunk.choices.size) 16 | assertEquals(ChatChunk.fake(), chunk.choices[0]) 17 | assertEquals("fake-content", chunk.content()) 18 | } 19 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/chat/api/ChatCompletionRequestTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | import com.tddworks.openai.api.common.prettyJson 4 | import kotlinx.serialization.ExperimentalSerializationApi 5 | import kotlinx.serialization.encodeToString 6 | import org.junit.jupiter.api.Assertions.* 7 | import org.junit.jupiter.api.Test 8 | 9 | @OptIn(ExperimentalSerializationApi::class) 10 | class ChatCompletionRequestTest { 11 | 12 | @Test 13 | fun `should return to correct stream json`() { 14 | val chatCompletionRequest = ChatCompletionRequest( 15 | messages = listOf( 16 | ChatMessage.user("hello") 17 | ), 18 | stream = true 19 | ) 20 | 21 | 22 | val result = prettyJson.encodeToString(chatCompletionRequest) 23 | 24 | assertEquals( 25 | """ 26 | { 27 | "messages": [ 28 | { 29 | "content": "hello", 30 | "role": "user" 31 | } 32 | ], 33 | "model": "gpt-3.5-turbo", 34 | "stream": true 35 | } 36 | """.trimIndent(), 37 | result 38 | ) 39 | } 40 | 41 | @Test 42 | fun `should return to correct json`() { 43 | val chatCompletionRequest = ChatCompletionRequest( 44 | messages = listOf( 45 | ChatMessage.user("hello") 46 | ) 47 | ) 48 | 49 | 50 | val result = prettyJson.encodeToString(ChatCompletionRequest.serializer(), chatCompletionRequest) 51 | 52 | assertEquals( 53 | """ 54 | { 55 | "messages": [ 56 | { 57 | "content": "hello", 58 | "role": "user" 59 | } 60 | ], 61 | "model": "gpt-3.5-turbo" 62 | } 63 | """.trimIndent(), 64 | result 65 | ) 66 | } 67 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/chat/api/ChatCompletionTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | class ChatCompletionTest { 7 | 8 | @Test 9 | fun `should return correct dummy chatCompletion`() { 10 | val chatCompletion = ChatCompletion.dummy() 11 | 12 | assertEquals("chatcmpl-8Zu4AF8QMK3zFgdzXIPjFS4VkWErX", chatCompletion.id) 13 | assertEquals(1634160000, chatCompletion.created) 14 | assertEquals("gpt-3.5-turbo", chatCompletion.model) 15 | assertEquals(1, chatCompletion.choices.size) 16 | assertEquals(ChatChoice.dummy(), chatCompletion.choices[0]) 17 | } 18 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/chat/api/ModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.chat.api 2 | 3 | 4 | import org.junit.jupiter.api.Assertions.assertEquals 5 | import org.junit.jupiter.api.Test 6 | 7 | class ModelTest { 8 | 9 | @Test 10 | fun `should return model from value`() { 11 | assertEquals(OpenAIModel.GPT_3_5_TURBO, OpenAIModel("gpt-3.5-turbo")) 12 | assertEquals(OpenAIModel.GPT_3_5_TURBO_0125, OpenAIModel("gpt-3.5-turbo-0125")) 13 | assertEquals(OpenAIModel.GPT_4_TURBO, OpenAIModel("gpt-4-turbo")) 14 | assertEquals(OpenAIModel.GPT4_VISION_PREVIEW, OpenAIModel("gpt-4-vision-preview")) 15 | assertEquals(OpenAIModel.GPT_4O, OpenAIModel("gpt-4o")) 16 | assertEquals(OpenAIModel.DALL_E_2, OpenAIModel("dall-e-2")) 17 | assertEquals(OpenAIModel.DALL_E_3, OpenAIModel("dall-e-3")) 18 | } 19 | 20 | @Test 21 | fun `should return correct model values`() { 22 | assertEquals("gpt-3.5-turbo", OpenAIModel.GPT_3_5_TURBO.value) 23 | assertEquals("gpt-3.5-turbo-0125", OpenAIModel.GPT_3_5_TURBO_0125.value) 24 | assertEquals("gpt-4-turbo", OpenAIModel.GPT_4_TURBO.value) 25 | assertEquals("gpt-4-vision-preview", OpenAIModel.GPT4_VISION_PREVIEW.value) 26 | assertEquals("gpt-4o", OpenAIModel.GPT_4O.value) 27 | assertEquals("dall-e-2", OpenAIModel.DALL_E_2.value) 28 | assertEquals("dall-e-3", OpenAIModel.DALL_E_3.value) 29 | } 30 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/common/JsonUtils.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.common 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | val prettyJson = Json { // this returns the JsonBuilder 6 | prettyPrint = true 7 | ignoreUnknownKeys = true 8 | // optional: specify indent 9 | prettyPrintIndent = " " 10 | } 11 | 12 | /** 13 | * Represents a JSON object that allows for leniency and ignores unknown keys. 14 | * 15 | * @property isLenient Removes JSON specification restriction (RFC-4627) and makes parser more liberal to the malformed input. In lenient mode quoted boolean literals, and unquoted string literals are allowed. 16 | * Its relaxations can be expanded in the future, so that lenient parser becomes even more permissive to invalid value in the input, replacing them with defaults. 17 | * false by default. 18 | * @property ignoreUnknownKeys Specifies whether encounters of unknown properties in the input JSON should be ignored instead of throwing SerializationException. false by default.. 19 | */ 20 | internal val JsonLenient = Json { 21 | isLenient = true 22 | ignoreUnknownKeys = true 23 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/common/MockHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.common 2 | 3 | 4 | import io.ktor.client.* 5 | import io.ktor.client.engine.mock.* 6 | import io.ktor.client.plugins.* 7 | import io.ktor.client.plugins.contentnegotiation.* 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | import io.ktor.serialization.kotlinx.* 11 | import io.ktor.serialization.kotlinx.json.* 12 | import kotlinx.serialization.json.Json 13 | 14 | /** 15 | * See https://ktor.io/docs/http-client-testing.html#usage 16 | */ 17 | fun mockHttpClient(mockResponse: String) = HttpClient(MockEngine) { 18 | 19 | val headers = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) 20 | 21 | install(ContentNegotiation) { 22 | register(ContentType.Application.Json, KotlinxSerializationConverter(JsonLenient)) 23 | } 24 | 25 | engine { 26 | addHandler { request -> 27 | if (request.url.encodedPath == "/v1/chat/completions" 28 | || request.url.encodedPath == "/v1/images/generations" 29 | || request.url.encodedPath == "/v1/completions" 30 | ) { 31 | respond(mockResponse, HttpStatusCode.OK, headers) 32 | } else { 33 | error("Unhandled ${request.url.encodedPath}") 34 | } 35 | } 36 | } 37 | 38 | defaultRequest { 39 | url { 40 | protocol = URLProtocol.HTTPS 41 | host = "api.lemonsqueezy.com" 42 | } 43 | 44 | header(HttpHeaders.ContentType, ContentType.Application.Json) 45 | contentType(ContentType.Application.Json) 46 | } 47 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/images/api/ImageCreateTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.api 2 | 3 | import com.tddworks.openai.api.chat.api.OpenAIModel 4 | import com.tddworks.openai.api.common.prettyJson 5 | import kotlinx.serialization.encodeToString 6 | import org.junit.jupiter.api.Assertions.assertEquals 7 | import org.junit.jupiter.api.Test 8 | 9 | class ImageCreateTest { 10 | 11 | @Test 12 | fun `should create image with custom model setting`() { 13 | // Given 14 | val json = """ 15 | { 16 | "prompt": "some prompt", 17 | "model": "dall-e-3" 18 | } 19 | """.trimIndent() 20 | 21 | val createImage = ImageCreate.create( 22 | prompt = "some prompt", 23 | size = null, 24 | style = null, 25 | quality = null, 26 | model = OpenAIModel.DALL_E_3, 27 | ) 28 | 29 | // When 30 | val result = prettyJson.encodeToString(createImage) 31 | 32 | // Then 33 | assertEquals(json, result) 34 | } 35 | 36 | @Test 37 | fun `should create image with all custom settings`() { 38 | // Given 39 | val json = """ 40 | { 41 | "prompt": "some prompt", 42 | "model": "dall-e-3", 43 | "response_format": "url", 44 | "size": "1024x1024", 45 | "style": "vivid", 46 | "quality": "hd" 47 | } 48 | """.trimIndent() 49 | 50 | val createImage = ImageCreate.create( 51 | prompt = "some prompt", 52 | model = OpenAIModel.DALL_E_3, 53 | size = Size.size1024x1024, 54 | style = Style.vivid, 55 | quality = Quality.hd, 56 | format = ResponseFormat.url, 57 | ) 58 | 59 | // When 60 | val result = prettyJson.encodeToString(createImage) 61 | 62 | // Then 63 | assertEquals(json, result) 64 | } 65 | 66 | @Test 67 | fun `should create image with default settings`() { 68 | // Given 69 | val json = """ 70 | { 71 | "prompt": "A cute baby sea otter", 72 | "model": "dall-e-3" 73 | } 74 | """.trimIndent() 75 | 76 | val createImage = ImageCreate.create( 77 | prompt = "A cute baby sea otter", 78 | ) 79 | 80 | // When 81 | val result = prettyJson.encodeToString(createImage) 82 | 83 | // Then 84 | assertEquals(json, result) 85 | } 86 | 87 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/images/api/ResponseFormatTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.api 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | class ResponseFormatTest { 7 | @Test 8 | fun `should return correct base 64 format type`() { 9 | assertEquals("b64_json", ResponseFormat.base64.value) 10 | } 11 | 12 | @Test 13 | fun `should return correct url format type`() { 14 | assertEquals("url", ResponseFormat.url.value) 15 | } 16 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/images/internal/DefaultImagesApiTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.images.internal 2 | 3 | import com.tddworks.openai.api.chat.api.OpenAIModel 4 | import com.tddworks.openai.api.common.mockHttpClient 5 | import com.tddworks.openai.api.images.api.ImageCreate 6 | import com.tddworks.common.network.api.ktor.internal.DefaultHttpRequester 7 | import com.tddworks.openai.api.images.api.Images 8 | import kotlinx.coroutines.runBlocking 9 | import org.junit.jupiter.api.Assertions.assertEquals 10 | import org.junit.jupiter.api.Test 11 | 12 | class DefaultImagesApiTest { 13 | 14 | @Test 15 | fun `should return correct image generations path url`() { 16 | assertEquals("/v1/images/generations", Images.IMAGES_GENERATIONS_PATH) 17 | } 18 | 19 | @Test 20 | fun `should return generated images`() = runBlocking { 21 | val request = ImageCreate( 22 | prompt = "Hello! How can I assist you today?", 23 | model = OpenAIModel.DALL_E_3 24 | ) 25 | 26 | val chat = DefaultImagesApi( 27 | requester = DefaultHttpRequester( 28 | httpClient = mockHttpClient( 29 | """ 30 | { 31 | "created": 1589478378, 32 | "data": [ 33 | { 34 | "url": "https://..." 35 | }, 36 | { 37 | "url": "https://..." 38 | } 39 | ] 40 | } 41 | """.trimIndent() 42 | ) 43 | ) 44 | ) 45 | 46 | 47 | val r = chat.generate(request) 48 | 49 | with(r) { 50 | assertEquals(1589478378, created) 51 | assertEquals(2, data.size) 52 | assertEquals("https://...", data[0].url) 53 | assertEquals("https://...", data[1].url) 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/legacy/completions/api/CompletionRequestTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.legacy.completions.api 2 | 3 | import com.tddworks.openai.api.common.prettyJson 4 | import kotlinx.serialization.encodeToString 5 | import org.junit.jupiter.api.Assertions.* 6 | import org.junit.jupiter.api.Test 7 | 8 | 9 | class CompletionRequestTest { 10 | @Test 11 | fun `should return to correct stream json`() { 12 | val chatCompletionRequest = CompletionRequest.asStream( 13 | prompt = "some-prompt", 14 | suffix = "some-suffix" 15 | ) 16 | 17 | val result = prettyJson.encodeToString(chatCompletionRequest) 18 | 19 | assertEquals( 20 | """ 21 | { 22 | "model": "gpt-3.5-turbo-instruct", 23 | "prompt": "some-prompt", 24 | "stream": true, 25 | "suffix": "some-suffix" 26 | } 27 | """.trimIndent(), 28 | result 29 | ) 30 | } 31 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/api/legacy/completions/api/internal/DefaultCompletionsTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.api.legacy.completions.api.internal 2 | 3 | import com.tddworks.common.network.api.ktor.internal.DefaultHttpRequester 4 | import com.tddworks.openai.api.common.mockHttpClient 5 | import com.tddworks.openai.api.legacy.completions.api.CompletionRequest 6 | import kotlinx.coroutines.runBlocking 7 | import org.junit.jupiter.api.Assertions.assertEquals 8 | import org.junit.jupiter.api.Test 9 | 10 | 11 | class DefaultCompletionsTest { 12 | 13 | @Test 14 | fun `should return completion`() = runBlocking { 15 | val request = CompletionRequest( 16 | prompt = "Once upon a time", 17 | suffix = "The end", 18 | maxTokens = 10, 19 | temperature = 0.5 20 | ) 21 | 22 | val completions = DefaultCompletionsApi( 23 | requester = DefaultHttpRequester( 24 | httpClient = mockHttpClient( 25 | """ 26 | { 27 | "id": "cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7", 28 | "object": "text_completion", 29 | "created": 1589478378, 30 | "model": "gpt-3.5-turbo-instruct", 31 | "system_fingerprint": "fp_44709d6fcb", 32 | "choices": [ 33 | { 34 | "text": "\n\nThis is indeed a test", 35 | "index": 0, 36 | "logprobs": null, 37 | "finish_reason": "length" 38 | } 39 | ], 40 | "usage": { 41 | "prompt_tokens": 5, 42 | "completion_tokens": 7, 43 | "total_tokens": 12 44 | } 45 | } 46 | """.trimIndent() 47 | ) 48 | ) 49 | ) 50 | 51 | 52 | val r = completions.completions(request) 53 | 54 | with(r) { 55 | assertEquals("cmpl-uqkvlQyYK7bGYrRHQ0eXlWi7", id) 56 | assertEquals(1589478378, created) 57 | assertEquals("gpt-3.5-turbo-instruct", model) 58 | assertEquals(1, choices.size) 59 | assertEquals("\n\nThis is indeed a test", choices[0].text) 60 | assertEquals(0, choices[0].index) 61 | assertEquals("length", choices[0].finishReason) 62 | assertEquals(5, usage?.promptTokens) 63 | assertEquals(7, usage?.completionTokens) 64 | assertEquals(12, usage?.totalTokens) 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /openai-client/openai-client-core/src/jvmTest/kotlin/com/tddworks/openai/di/OpenAIKoinTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.di 2 | 3 | import com.tddworks.di.getInstance 4 | import com.tddworks.openai.api.OpenAI 5 | import com.tddworks.openai.api.OpenAIConfig 6 | import kotlinx.serialization.json.Json 7 | import org.junit.jupiter.api.Test 8 | import org.koin.dsl.koinApplication 9 | import org.koin.test.check.checkModules 10 | import org.koin.test.junit5.AutoCloseKoinTest 11 | 12 | class OpenAIKoinTest : AutoCloseKoinTest() { 13 | @Test 14 | fun `should initialize common koin modules`() { 15 | koinApplication { 16 | initOpenAI(OpenAIConfig( 17 | baseUrl = { OpenAI.BASE_URL }, 18 | apiKey = { System.getenv("OPENAI_API_KEY") ?: "CONFIGURE_ME" } 19 | )) 20 | }.checkModules() 21 | 22 | val json = getInstance() 23 | 24 | kotlin.test.assertNotNull(json) 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /openai-client/openai-client-darwin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.touchlab.kmmbridge) 4 | alias(libs.plugins.touchlab.skie) 5 | `maven-publish` 6 | } 7 | 8 | kotlin { 9 | listOf( 10 | macosArm64(), 11 | iosArm64(), 12 | iosSimulatorArm64() 13 | ).forEach { macosTarget -> 14 | macosTarget.binaries.framework { 15 | baseName = "openai-client-darwin" 16 | export(projects.openaiClient.openaiClientCore) 17 | isStatic = true 18 | } 19 | } 20 | 21 | sourceSets { 22 | commonMain { 23 | dependencies { 24 | api(projects.openaiClient.openaiClientCore) 25 | implementation(libs.ktor.client.darwin) 26 | } 27 | } 28 | appleMain {} 29 | } 30 | } 31 | 32 | addGithubPackagesRepository() // <- Add the GitHub Packages repo 33 | 34 | kmmbridge { 35 | /** 36 | * reference: https://kmmbridge.touchlab.co/docs/artifacts/MAVEN_REPO_ARTIFACTS#github-packages 37 | * In kmmbridge, notice mavenPublishArtifacts() tells the plugin to push KMMBridge artifacts to a Maven repo. You then need to define a repo. Rather than do everything manually, you can just call addGithubPackagesRepository(), which will add the correct repo given parameters that are passed in from GitHub Actions. 38 | */ 39 | mavenPublishArtifacts() // <- Publish using a Maven repo 40 | // spm(swiftToolVersion = "5.9") 41 | spm( 42 | swiftToolVersion = "5.9", 43 | // useCustomPackageFile = true, 44 | // perModuleVariablesBlock = true 45 | ) { 46 | iOS { v("15") } 47 | // macOS { v("15") } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /openai-client/openai-client-darwin/src/appleMain/kotlin/com/tddworks/openai/darwin/api/DarwinOpenAI.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.darwin.api 2 | 3 | import com.tddworks.openai.api.OpenAI 4 | import com.tddworks.openai.api.OpenAIConfig 5 | import com.tddworks.openai.di.initOpenAI 6 | 7 | /** 8 | * Object for accessing OpenAI API functions 9 | */ 10 | object DarwinOpenAI { 11 | 12 | /** 13 | * Function to initialize the OpenAI API client with the given API key and base URL. 14 | * 15 | * @param apiKey A lambda function that returns the API key to be used for authentication. Defaults to "CONFIG_API_KEY". 16 | * @param baseUrl A lambda function that returns the base URL of the OpenAI API. Defaults to the constant OpenAI.BASE_URL. 17 | */ 18 | fun openAI( 19 | apiKey: () -> String = { "CONFIG_API_KEY" }, 20 | baseUrl: () -> String = { OpenAI.BASE_URL }, 21 | ) = initOpenAI(OpenAIConfig(apiKey, baseUrl)) 22 | } -------------------------------------------------------------------------------- /openai-gateway/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `maven-publish` 3 | } 4 | 5 | kotlin { 6 | jvm() 7 | sourceSets { 8 | commonMain { 9 | dependencies { 10 | api(projects.openaiGateway.openaiGatewayCore) 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /openai-gateway/jvm/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tddworks/openai-kotlin/02e262493ef8b80ee89dd6ca8e705efd136fef13/openai-gateway/jvm/.gitkeep -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.kover) 4 | `maven-publish` 5 | } 6 | 7 | kotlin { 8 | jvm() 9 | macosArm64() 10 | iosArm64() 11 | iosSimulatorArm64() 12 | 13 | sourceSets { 14 | commonMain.dependencies { 15 | api(projects.geminiClient.geminiClientCore) 16 | api(projects.openaiClient.openaiClientCore) 17 | api(projects.anthropicClient.anthropicClientCore) 18 | api(projects.ollamaClient.ollamaClientCore) 19 | } 20 | 21 | commonTest.dependencies { 22 | implementation(libs.ktor.client.mock) 23 | } 24 | 25 | macosMain.dependencies { 26 | api(libs.ktor.client.darwin) 27 | } 28 | 29 | jvmMain.dependencies { 30 | api(libs.ktor.client.cio) 31 | } 32 | 33 | jvmTest.dependencies { 34 | implementation(project.dependencies.platform(libs.junit.bom)) 35 | implementation(libs.bundles.jvm.test) 36 | implementation(libs.kotlinx.coroutines.test) 37 | implementation(libs.koin.test) 38 | implementation(libs.koin.test.junit5) 39 | implementation(libs.app.cash.turbine) 40 | implementation("com.tngtech.archunit:archunit-junit5:1.1.0") 41 | implementation("org.reflections:reflections:0.10.2") 42 | } 43 | } 44 | } 45 | 46 | tasks { 47 | named("jvmTest") { 48 | useJUnitPlatform() 49 | } 50 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/OpenAIStopReason.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api.messages.api 2 | 3 | enum class OpenAIStopReason { 4 | Stop, 5 | Length, 6 | FunctionCall, 7 | ToolCalls, 8 | Other 9 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/azure/api/internal/AzureChatApi.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.azure.api.internal 2 | 3 | import com.tddworks.common.network.api.ktor.api.HttpRequester 4 | import com.tddworks.openai.api.chat.api.Chat 5 | import com.tddworks.openai.api.chat.internal.default 6 | 7 | internal class AzureChatApi( 8 | private val chatCompletionPath: String, 9 | private val requester: HttpRequester, 10 | private val extraHeaders: Map = mapOf(), 11 | private val chatApi: Chat = Chat.default( 12 | requester = requester, 13 | chatCompletionPath = chatCompletionPath, 14 | extraHeaders = extraHeaders 15 | ) 16 | ) : Chat by chatApi { 17 | companion object { 18 | const val BASE_URL = "https://YOUR_RESOURCE_NAME.openai.azure.com" 19 | const val CHAT_COMPLETIONS = "chat/completions" 20 | } 21 | } 22 | 23 | fun Chat.Companion.azure( 24 | apiKey: () -> String, 25 | requester: HttpRequester, 26 | chatCompletionPath: String = AzureChatApi.CHAT_COMPLETIONS, 27 | ): Chat = AzureChatApi( 28 | requester = requester, 29 | chatCompletionPath = chatCompletionPath, 30 | extraHeaders = mapOf("api-key" to apiKey()) 31 | ) -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/LLMProvider.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api 2 | 3 | enum class LLMProvider { 4 | ANTHROPIC, 5 | DEEPSEEK, 6 | GEMINI, 7 | MOONSHOT, 8 | OLLAMA, 9 | OPENAI 10 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/OpenAIGateway.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api 2 | 3 | import com.tddworks.common.network.api.ktor.api.ListResponse 4 | import com.tddworks.openai.api.chat.api.ChatCompletion 5 | import com.tddworks.openai.api.chat.api.ChatCompletionChunk 6 | import com.tddworks.openai.api.chat.api.ChatCompletionRequest 7 | import com.tddworks.openai.api.images.api.Image 8 | import com.tddworks.openai.api.images.api.ImageCreate 9 | import com.tddworks.openai.api.legacy.completions.api.Completion 10 | import com.tddworks.openai.api.legacy.completions.api.CompletionRequest 11 | import kotlinx.coroutines.flow.Flow 12 | 13 | /** 14 | * Interface for connecting to the OpenAI Gateway to chat. 15 | */ 16 | interface OpenAIGateway { 17 | fun updateProvider( 18 | id: String, 19 | name: String, 20 | config: OpenAIProviderConfig 21 | ) 22 | 23 | fun addProvider(provider: OpenAIProvider): OpenAIGateway 24 | fun removeProvider(id: String) 25 | fun getProviders(): List 26 | 27 | /** 28 | * Creates an image given a prompt. 29 | * Get images as URLs or base64-encoded JSON. 30 | * @param request image creation request. 31 | * @return list of images. 32 | */ 33 | suspend fun generate(request: ImageCreate, provider: LLMProvider): ListResponse 34 | 35 | /** 36 | * Fetch a completion. 37 | * @param request The request to fetch a completion. 38 | * @param provider The provider to use for the completion. 39 | * @return The completion 40 | */ 41 | suspend fun completions(request: CompletionRequest, provider: LLMProvider): Completion 42 | 43 | /** 44 | * Fetch a chat completion. 45 | * @param request The request to fetch a chat completion. 46 | * @param provider The provider to use for the chat completion. 47 | * @return The chat completion 48 | */ 49 | suspend fun chatCompletions(request: ChatCompletionRequest, provider: LLMProvider): ChatCompletion 50 | 51 | 52 | /** 53 | * Stream a chat completion. 54 | * @param request The request to stream a chat completion. 55 | * @param provider The provider to use for the chat completion. 56 | * @return The chat completion chunks as a stream 57 | */ 58 | fun streamChatCompletions(request: ChatCompletionRequest, provider: LLMProvider): Flow 59 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/OpenAIProvider.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api 2 | 3 | import com.tddworks.openai.api.chat.api.Chat 4 | import com.tddworks.openai.api.chat.api.OpenAIModel 5 | import com.tddworks.openai.api.images.api.Images 6 | import com.tddworks.openai.api.legacy.completions.api.Completions 7 | 8 | /** 9 | * Represents a provider for the OpenAI chat functionality. 10 | */ 11 | interface OpenAIProvider : Chat, Completions, Images { 12 | 13 | /** 14 | * The id of the provider. 15 | */ 16 | val id: String 17 | 18 | /** 19 | * The name of the provider. 20 | */ 21 | val name: String 22 | 23 | /** 24 | * The configuration for the provider. 25 | */ 26 | val config: OpenAIProviderConfig 27 | 28 | companion object 29 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/OpenAIProviderConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api 2 | 3 | /** 4 | * Represents the configuration for the OpenAI API. 5 | */ 6 | interface OpenAIProviderConfig { 7 | val apiKey: () -> String 8 | val baseUrl: () -> String 9 | companion object 10 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/internal/AnthropicOpenAIProviderConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api.internal 2 | 3 | import com.tddworks.anthropic.api.Anthropic 4 | import com.tddworks.anthropic.api.AnthropicConfig 5 | import com.tddworks.openai.gateway.api.OpenAIProviderConfig 6 | 7 | class AnthropicOpenAIProviderConfig( 8 | val anthropicVersion: () -> String = { Anthropic.ANTHROPIC_VERSION }, 9 | override val apiKey: () -> String, 10 | override val baseUrl: () -> String = { Anthropic.BASE_URL } 11 | ) : OpenAIProviderConfig 12 | 13 | fun AnthropicOpenAIProviderConfig.toAnthropicOpenAIConfig() = 14 | AnthropicConfig( 15 | apiKey = apiKey, 16 | baseUrl = baseUrl, 17 | anthropicVersion = anthropicVersion 18 | ) 19 | 20 | fun OpenAIProviderConfig.Companion.anthropic( 21 | apiKey: () -> String, 22 | baseUrl: () -> String = { Anthropic.BASE_URL }, 23 | anthropicVersion: () -> String = { Anthropic.ANTHROPIC_VERSION } 24 | ) = AnthropicOpenAIProviderConfig(anthropicVersion, apiKey, baseUrl) -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/internal/DefaultOpenAIProviderConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api.internal 2 | 3 | import com.tddworks.openai.api.OpenAI 4 | import com.tddworks.openai.api.OpenAIConfig 5 | import com.tddworks.openai.gateway.api.OpenAIProviderConfig 6 | 7 | data class DefaultOpenAIProviderConfig( 8 | override val apiKey: () -> String, 9 | override val baseUrl: () -> String = { OpenAI.BASE_URL } 10 | ) : OpenAIProviderConfig 11 | 12 | fun OpenAIProviderConfig.toOpenAIConfig() = OpenAIConfig(apiKey, baseUrl) 13 | 14 | fun OpenAIProviderConfig.Companion.default( 15 | apiKey: () -> String, 16 | baseUrl: () -> String = { OpenAI.BASE_URL } 17 | ) = DefaultOpenAIProviderConfig(apiKey, baseUrl) -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/internal/GeminiOpenAIProvider.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api.internal 2 | 3 | import com.tddworks.common.network.api.ktor.api.ListResponse 4 | import com.tddworks.gemini.api.textGeneration.api.* 5 | import com.tddworks.openai.api.chat.api.ChatCompletion 6 | import com.tddworks.openai.api.chat.api.ChatCompletionChunk 7 | import com.tddworks.openai.api.chat.api.ChatCompletionRequest 8 | import com.tddworks.openai.api.chat.api.OpenAIModel 9 | import com.tddworks.openai.api.images.api.Image 10 | import com.tddworks.openai.api.images.api.ImageCreate 11 | import com.tddworks.openai.api.legacy.completions.api.Completion 12 | import com.tddworks.openai.api.legacy.completions.api.CompletionRequest 13 | import com.tddworks.openai.gateway.api.OpenAIProvider 14 | import kotlinx.coroutines.flow.Flow 15 | import kotlinx.coroutines.flow.transform 16 | import kotlinx.serialization.ExperimentalSerializationApi 17 | 18 | @OptIn(ExperimentalSerializationApi::class) 19 | class GeminiOpenAIProvider( 20 | override val id: String = "gemini", 21 | override val name: String = "Gemini", 22 | override val config: GeminiOpenAIProviderConfig, 23 | val client: Gemini 24 | ) : OpenAIProvider { 25 | 26 | override suspend fun chatCompletions(request: ChatCompletionRequest): ChatCompletion { 27 | val geminiRequest = request.toGeminiGenerateContentRequest() 28 | return client.generateContent(geminiRequest).toOpenAIChatCompletion() 29 | } 30 | 31 | override fun streamChatCompletions(request: ChatCompletionRequest): Flow { 32 | val geminiRequest = request.toGeminiGenerateContentRequest().copy(stream = true) 33 | 34 | return client.streamGenerateContent(geminiRequest).transform { 35 | emit(it.toOpenAIChatCompletionChunk()) 36 | } 37 | } 38 | 39 | override suspend fun completions(request: CompletionRequest): Completion { 40 | throw UnsupportedOperationException("Not supported") 41 | } 42 | 43 | override suspend fun generate(request: ImageCreate): ListResponse { 44 | throw UnsupportedOperationException("Not supported") 45 | } 46 | } 47 | 48 | fun OpenAIProvider.Companion.gemini( 49 | id: String = "gemini", config: GeminiOpenAIProviderConfig, client: Gemini 50 | ): OpenAIProvider { 51 | return GeminiOpenAIProvider( 52 | id = id, config = config, client = client 53 | ) 54 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/internal/GeminiOpenAIProviderConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api.internal 2 | 3 | import com.tddworks.gemini.api.textGeneration.api.Gemini 4 | import com.tddworks.gemini.api.textGeneration.api.GeminiConfig 5 | import com.tddworks.openai.gateway.api.OpenAIProviderConfig 6 | 7 | class GeminiOpenAIProviderConfig( 8 | override val apiKey: () -> String, 9 | override val baseUrl: () -> String = { Gemini.BASE_URL } 10 | ) : OpenAIProviderConfig 11 | 12 | fun OpenAIProviderConfig.toGeminiConfig() = 13 | GeminiConfig( 14 | apiKey = apiKey, 15 | baseUrl = baseUrl, 16 | ) 17 | 18 | fun OpenAIProviderConfig.Companion.gemini( 19 | apiKey: () -> String, 20 | baseUrl: () -> String = { Gemini.BASE_URL }, 21 | ) = GeminiOpenAIProviderConfig(apiKey, baseUrl) -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/commonMain/kotlin/com/tddworks/openai/gateway/api/internal/OllamaOpenAIProviderConfig.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api.internal 2 | 3 | import com.tddworks.ollama.api.OllamaConfig 4 | import com.tddworks.openai.gateway.api.OpenAIProviderConfig 5 | 6 | data class OllamaOpenAIProviderConfig( 7 | val port: () -> Int = { 11434 }, 8 | val protocol: () -> String = { "http" }, 9 | override val baseUrl: () -> String = { "http//:localhost:11434" }, 10 | override val apiKey: () -> String = { "ollama-ignore-this" } 11 | ) : OpenAIProviderConfig 12 | 13 | fun OllamaOpenAIProviderConfig.toOllamaConfig() = 14 | OllamaConfig(baseUrl = baseUrl, protocol = protocol, port = port) 15 | 16 | fun OpenAIProviderConfig.Companion.ollama( 17 | apiKey: () -> String = { "ollama-ignore-this" }, 18 | baseUrl: () -> String = { "localhost" }, 19 | protocol: () -> String = { "http" }, 20 | port: () -> Int = { 11434 } 21 | ) = OllamaOpenAIProviderConfig(port, protocol, baseUrl, apiKey) -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/jvmMain/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tddworks/openai-kotlin/02e262493ef8b80ee89dd6ca8e705efd136fef13/openai-gateway/openai-gateway-core/src/jvmMain/.gitkeep -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/AnthropicConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | import org.junit.jupiter.api.Assertions.assertNotNull 4 | import org.junit.jupiter.api.Test 5 | import org.koin.core.context.startKoin 6 | import org.koin.test.junit5.AutoCloseKoinTest 7 | 8 | class AnthropicConfigTest : AutoCloseKoinTest() { 9 | 10 | @Test 11 | fun `fix the coverage by this case`() { 12 | startKoin { 13 | val r = AnthropicConfig().getKoin() 14 | assertNotNull(r) 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/AnthropicTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.anthropic.api 2 | 3 | import com.tddworks.anthropic.di.iniAnthropic 4 | import org.junit.jupiter.api.Assertions.* 5 | import org.junit.jupiter.api.BeforeEach 6 | import org.junit.jupiter.api.Test 7 | import org.koin.test.junit5.AutoCloseKoinTest 8 | 9 | 10 | class AnthropicTest : AutoCloseKoinTest() { 11 | 12 | @BeforeEach 13 | fun setUp() { 14 | iniAnthropic(config = AnthropicConfig()) 15 | } 16 | 17 | @Test 18 | fun `should create anthropic with default settings`() { 19 | 20 | val anthropic = Anthropic.create(anthropicConfig = AnthropicConfig()) 21 | 22 | assertEquals("CONFIG_API_KEY", anthropic.apiKey()) 23 | assertEquals(Anthropic.BASE_URL, anthropic.baseUrl()) 24 | assertEquals("2023-06-01", anthropic.anthropicVersion()) 25 | } 26 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/jvmTest/kotlin/com/tddworks/azure/api/AzureAIProviderConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.azure.api 2 | 3 | import com.tddworks.openai.gateway.api.OpenAIProviderConfig 4 | import org.junit.jupiter.api.Assertions.* 5 | import org.junit.jupiter.api.Test 6 | 7 | class AzureAIProviderConfigTest { 8 | 9 | @Test 10 | fun `should create AzureAIProviderConfig`() { 11 | val config = OpenAIProviderConfig.azure( 12 | apiKey = { "api-key" }, 13 | baseUrl = { "YOUR_RESOURCE_NAME.openai.azure.com" }, 14 | deploymentId = { "deployment-id" }, 15 | apiVersion = { "api-version" } 16 | ) 17 | 18 | assertEquals("api-key", config.apiKey()) 19 | assertEquals("YOUR_RESOURCE_NAME.openai.azure.com", config.baseUrl()) 20 | assertEquals("deployment-id", config.deploymentId()) 21 | assertEquals("api-version", config.apiVersion()) 22 | } 23 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/jvmTest/kotlin/com/tddworks/openai/gateway/api/internal/AnthropicOpenAIProviderConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.api.internal 2 | 3 | import com.tddworks.openai.gateway.api.OpenAIProviderConfig 4 | import org.junit.jupiter.api.Assertions.* 5 | import org.junit.jupiter.api.Test 6 | 7 | class AnthropicOpenAIProviderConfigTest { 8 | 9 | @Test 10 | fun `should create anthropic openai config`() { 11 | // When 12 | val r = OpenAIProviderConfig.anthropic( 13 | apiKey = { "apiKey" }, 14 | baseUrl = { "baseUrl" }, 15 | anthropicVersion = { "2023-06-01" } 16 | ) 17 | 18 | // Then 19 | assertEquals("apiKey", r.apiKey()) 20 | assertEquals("baseUrl", r.baseUrl()) 21 | assertEquals("2023-06-01", r.anthropicVersion()) 22 | } 23 | 24 | @Test 25 | fun `should convert to anthropic openai config`() { 26 | // Given 27 | val anthropicOpenAIProviderConfig = AnthropicOpenAIProviderConfig( 28 | anthropicVersion = { "2023-06-01" }, 29 | apiKey = { "apiKey" }, 30 | baseUrl = { "baseUrl" } 31 | ) 32 | 33 | // When 34 | val anthropicConfig = anthropicOpenAIProviderConfig.toAnthropicOpenAIConfig() 35 | 36 | // Then 37 | assertEquals("apiKey", anthropicConfig.apiKey()) 38 | assertEquals("baseUrl", anthropicConfig.baseUrl()) 39 | assertEquals("2023-06-01", anthropicConfig.anthropicVersion()) 40 | } 41 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/jvmTest/kotlin/com/tddworks/openai/gateway/di/KoinTest.kt: -------------------------------------------------------------------------------- 1 | package com.tddworks.openai.gateway.di 2 | 3 | import com.tddworks.openai.gateway.api.internal.AnthropicOpenAIProviderConfig 4 | import com.tddworks.openai.gateway.api.internal.DefaultOpenAIProviderConfig 5 | import com.tddworks.openai.gateway.api.internal.GeminiOpenAIProviderConfig 6 | import com.tddworks.openai.gateway.api.internal.OllamaOpenAIProviderConfig 7 | import org.junit.jupiter.api.Test 8 | import org.koin.dsl.koinApplication 9 | import org.koin.test.KoinTest 10 | import org.koin.test.check.checkModules 11 | 12 | class OpenAIGatewayKoinTest : KoinTest { 13 | 14 | @Test 15 | fun `should initialize OpenAI Gateway Koin modules`() { 16 | val openAIConfig = DefaultOpenAIProviderConfig(apiKey = { "" }) 17 | val anthropicConfig = AnthropicOpenAIProviderConfig(apiKey = { "" }) 18 | val ollamaConfig = OllamaOpenAIProviderConfig() 19 | val geminiConfig = GeminiOpenAIProviderConfig(apiKey = { "" }) 20 | 21 | koinApplication { 22 | initOpenAIGateway(openAIConfig, anthropicConfig, ollamaConfig, geminiConfig) 23 | }.checkModules() 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-core/src/macosMain/kotlin/com/tddworks/openai/gateway/di/Koin.macos.kt: -------------------------------------------------------------------------------- 1 | import org.koin.core.module.Module 2 | 3 | fun platformModule(): Module { 4 | TODO("Not yet implemented") 5 | } -------------------------------------------------------------------------------- /openai-gateway/openai-gateway-darwin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinx.serialization) 3 | alias(libs.plugins.touchlab.kmmbridge) 4 | alias(libs.plugins.touchlab.skie) 5 | `maven-publish` 6 | } 7 | 8 | kotlin { 9 | listOf( 10 | macosArm64(), 11 | iosArm64(), 12 | iosSimulatorArm64() 13 | ).forEach { macosTarget -> 14 | macosTarget.binaries.framework { 15 | baseName = "openai-gateway-darwin" 16 | export(projects.openaiGateway.openaiGatewayCore) 17 | export(projects.openaiClient.openaiClientDarwin) 18 | export(projects.ollamaClient.ollamaClientDarwin) 19 | export(projects.anthropicClient.anthropicClientDarwin) 20 | isStatic = true 21 | } 22 | } 23 | 24 | sourceSets { 25 | commonMain { 26 | dependencies { 27 | api(projects.openaiGateway.openaiGatewayCore) 28 | api(projects.openaiClient.openaiClientDarwin) 29 | api(projects.ollamaClient.ollamaClientDarwin) 30 | api(projects.anthropicClient.anthropicClientDarwin) 31 | implementation(libs.ktor.client.darwin) 32 | } 33 | } 34 | appleMain {} 35 | } 36 | } 37 | 38 | addGithubPackagesRepository() // <- Add the GitHub Packages repo 39 | 40 | kmmbridge { 41 | /** 42 | * reference: https://kmmbridge.touchlab.co/docs/artifacts/MAVEN_REPO_ARTIFACTS#github-packages 43 | * In kmmbridge, notice mavenPublishArtifacts() tells the plugin to push KMMBridge artifacts to a Maven repo. You then need to define a repo. Rather than do everything manually, you can just call addGithubPackagesRepository(), which will add the correct repo given parameters that are passed in from GitHub Actions. 44 | */ 45 | mavenPublishArtifacts() // <- Publish using a Maven repo 46 | 47 | // spm { 48 | // swiftToolsVersion = "5.9" 49 | // platforms { 50 | // iOS("14") 51 | // macOS("13") 52 | // watchOS("7") 53 | // tvOS("14") 54 | // } 55 | // } 56 | } 57 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenLocal() 5 | mavenCentral() 6 | gradlePluginPortal() 7 | } 8 | } 9 | 10 | dependencyResolutionManagement { 11 | repositories { 12 | google() 13 | mavenCentral() 14 | } 15 | } 16 | 17 | rootProject.name = "openai-kotlin" 18 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 19 | 20 | 21 | plugins { 22 | id("de.fayard.refreshVersions") version "0.60.5" 23 | } 24 | 25 | fun String.isNonStable(): Boolean { 26 | val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { uppercase().contains(it) } 27 | val regex = "^[0-9,.v-]+(-r)?$".toRegex() 28 | val isStable = stableKeyword || regex.matches(this) 29 | return isStable.not() 30 | } 31 | 32 | refreshVersions { 33 | rejectVersionIf { 34 | candidate.value.isNonStable() 35 | } 36 | } 37 | 38 | 39 | include(":common") 40 | 41 | //include(":library") 42 | include(":openai-client") 43 | include(":openai-client:openai-client-core") 44 | include(":openai-client:openai-client-darwin") 45 | include(":openai-client:openai-client-cio") 46 | 47 | include(":anthropic-client") 48 | include(":anthropic-client:anthropic-client-core") 49 | include(":anthropic-client:anthropic-client-darwin") 50 | 51 | include(":openai-gateway") 52 | include(":openai-gateway:openai-gateway-core") 53 | include(":openai-gateway:openai-gateway-darwin") 54 | 55 | include(":ollama-client") 56 | include(":ollama-client:ollama-client-core") 57 | include(":ollama-client:ollama-client-darwin") 58 | 59 | include(":gemini-client") 60 | include(":gemini-client:gemini-client-core") 61 | include(":gemini-client:gemini-client-darwin") --------------------------------------------------------------------------------