diff --git a/gemini-client/build.gradle.kts b/gemini-client/build.gradle.kts new file mode 100644 index 0000000..80f230c --- /dev/null +++ b/gemini-client/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `maven-publish` +} + +kotlin { + jvm() + sourceSets { + commonMain { + dependencies { + api(projects.geminiClient.geminiClientCore) + } + } + } +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/build.gradle.kts b/gemini-client/gemini-client-core/build.gradle.kts new file mode 100644 index 0000000..9aa47ee --- /dev/null +++ b/gemini-client/gemini-client-core/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.kover) + `maven-publish` +} + +kotlin { + jvm() + macosArm64() + macosX64() + + sourceSets { + commonMain.dependencies { + // put your Multiplatform dependencies here + api(projects.common) + } + + commonTest.dependencies { + implementation(libs.ktor.client.mock) + api(projects.common) + } + + macosMain.dependencies { + api(libs.ktor.client.darwin) + } + + jvmMain.dependencies { + api(libs.ktor.client.cio) + } + + jvmTest.dependencies { + implementation(project.dependencies.platform(libs.junit.bom)) + implementation(libs.bundles.jvm.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.koin.test) + implementation(libs.koin.test.junit5) + implementation(libs.app.cash.turbine) + implementation("com.tngtech.archunit:archunit-junit5:1.1.0") + implementation("org.reflections:reflections:0.10.2") + } + } +} + +tasks { + named("jvmTest") { + useJUnitPlatform() + } +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/Gemini.kt b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/Gemini.kt new file mode 100644 index 0000000..9049a6a --- /dev/null +++ b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/Gemini.kt @@ -0,0 +1,8 @@ +package com.tddworks.gemini.api.textGeneration.api + +interface Gemini : TextGeneration { + companion object { + const val HOST = "generativelanguage.googleapis.com" + const val BASE_URL = "https://$HOST" + } +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GeminiConfig.kt b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GeminiConfig.kt new file mode 100644 index 0000000..5b9262a --- /dev/null +++ b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GeminiConfig.kt @@ -0,0 +1,6 @@ +package com.tddworks.gemini.api.textGeneration.api + +data class GeminiConfig( + val apiKey: () -> String = { "CONFIG_API_KEY" }, + val baseUrl: () -> String = { Gemini.BASE_URL }, +) diff --git a/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GeminiModel.kt b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GeminiModel.kt new file mode 100644 index 0000000..eaf2ddf --- /dev/null +++ b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GeminiModel.kt @@ -0,0 +1,42 @@ +package com.tddworks.gemini.api.textGeneration.api + +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline + +/** + * https://ai.google.dev/gemini-api/docs/models/gemini + */ +@Serializable +@JvmInline +value class GeminiModel(val value: String) { + companion object { + + /** + * Input(s): Audio, images, videos, and text + * Output(s): Text + * Optimized for: Complex reasoning tasks requiring more intelligence + */ + val GEMINI_1_5_PRO = GeminiModel("gemini-1.5-pro") + + /** + * Input(s): Audio, images, videos, and text + * Output(s): Text + * Optimized for: High volume and lower intelligence tasks + */ + val GEMINI_1_5_FLASH_8b = GeminiModel("gemini-1.5-flash-8b") + + /** + * Input(s): Audio, images, videos, and text + * Output(s): Text + * Optimized for: Fast and versatile performance across a diverse variety of tasks + */ + val GEMINI_1_5_FLASH = GeminiModel("gemini-1.5-flash") + + /** + * Input(s): Audio, images, videos, and text + * Output(s): Text, images (coming soon), and audio (coming soon) + * Optimized for: Next generation features, speed, and multimodal generation for a diverse variety of tasks + */ + val GEMINI_2_0_FLASH = GeminiModel("gemini-2.0-flash-exp") + } +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentRequest.kt b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentRequest.kt new file mode 100644 index 0000000..74ba529 --- /dev/null +++ b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentRequest.kt @@ -0,0 +1,43 @@ +package com.tddworks.gemini.api.textGeneration.api + +import com.tddworks.gemini.api.textGeneration.api.internal.DefaultTextGenerationApi.Companion.GEMINI_API_PATH +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +// curl https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:streamGenerateContent?alt=sse&key=$GOOGLE_API_KEY \ +// curl https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=$GOOGLE_API_KEY \ + +/** + * { + * "contents": [ + * {"role":"user", + * "parts":[{ + * "text": "Hello"}]}, + * {"role": "model", + * "parts":[{ + * "text": "Great to meet you. What would you like to know?"}]}, + * {"role":"user", + * "parts":[{ + * "text": "I have two dogs in my house. How many paws are in my house?"}]}, + * ] + * } + */ +@Serializable +data class GenerateContentRequest( + val contents: List, + @Transient + val model: GeminiModel = GeminiModel.GEMINI_1_5_FLASH, + @Transient + val stream: Boolean = false, + @Transient + val apiKey: String = "" +) { + fun toRequestUrl(): String { + val endpoint = if (stream) { + "streamGenerateContent?alt=sse&key=$apiKey" + } else { + "generateContent?key=$apiKey" + } + return "$GEMINI_API_PATH/${model.value}:$endpoint" + } +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentResponse.kt b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentResponse.kt new file mode 100644 index 0000000..ceb5917 --- /dev/null +++ b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentResponse.kt @@ -0,0 +1,35 @@ +package com.tddworks.gemini.api.textGeneration.api + +import kotlinx.serialization.Serializable + +@Serializable +data class GenerateContentResponse( + val candidates: List, + val usageMetadata: UsageMetadata, + val modelVersion: String +) + +@Serializable +data class Candidate( + val content: Content, + val finishReason: String, + val avgLogprobs: Double +) + +@Serializable +data class Content( + val parts: List, + val role: String +) + +@Serializable +data class Part( + val text: String +) + +@Serializable +data class UsageMetadata( + val promptTokenCount: Int, + val candidatesTokenCount: Int, + val totalTokenCount: Int +) diff --git a/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/TextGeneration.kt b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/TextGeneration.kt new file mode 100644 index 0000000..cf13c4b --- /dev/null +++ b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/TextGeneration.kt @@ -0,0 +1,17 @@ +package com.tddworks.gemini.api.textGeneration.api + +import kotlinx.coroutines.flow.Flow + +/** + * https://ai.google.dev/api/generate-content#v1beta.Candidate + */ +interface TextGeneration { + suspend fun generateContent(request: GenerateContentRequest): GenerateContentResponse + + /** + * 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"} + * + * 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"} + */ + fun streamGenerateContent(request: GenerateContentRequest): Flow +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/internal/DefaultTextGenerationApi.kt b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/internal/DefaultTextGenerationApi.kt new file mode 100644 index 0000000..f5e2d84 --- /dev/null +++ b/gemini-client/gemini-client-core/src/commonMain/kotlin/com/tddworks/gemini/api/textGeneration/api/internal/DefaultTextGenerationApi.kt @@ -0,0 +1,31 @@ +package com.tddworks.gemini.api.textGeneration.api.internal + +import com.tddworks.common.network.api.ktor.api.HttpRequester +import com.tddworks.common.network.api.ktor.api.performRequest +import com.tddworks.gemini.api.textGeneration.api.GenerateContentRequest +import com.tddworks.gemini.api.textGeneration.api.GenerateContentResponse +import com.tddworks.gemini.api.textGeneration.api.TextGeneration +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.coroutines.flow.Flow + +class DefaultTextGenerationApi( + private val requester: HttpRequester +) : TextGeneration { + override suspend fun generateContent(request: GenerateContentRequest): GenerateContentResponse { + return requester.performRequest { + method = HttpMethod.Post + url(path = request.toRequestUrl()) + setBody(request) + contentType(ContentType.Application.Json) + } + } + + override fun streamGenerateContent(request: GenerateContentRequest): Flow { + TODO("Not yet implemented") + } + + companion object { + const val GEMINI_API_PATH = "/v1beta/models" + } +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentRequestTest.kt b/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentRequestTest.kt new file mode 100644 index 0000000..3b0c432 --- /dev/null +++ b/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentRequestTest.kt @@ -0,0 +1,47 @@ +package com.tddworks.gemini.api.textGeneration.api + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class GenerateContentRequestTest { + + @Test + fun `should return correct streamGenerateContent request url`() { + // Given + val generateContentRequest = GenerateContentRequest( + contents = listOf(), + stream = true, + apiKey = "some-key" + ) + + // When + val result = generateContentRequest.toRequestUrl() + + // Then + assertEquals( + "/v1beta/models/gemini-1.5-flash:streamGenerateContent?alt=sse&key=some-key", + result + ) + } + + + @Test + fun `should return correct generateContent request url`() { + // Given + val generateContentRequest = GenerateContentRequest( + contents = listOf(), + stream = false, + apiKey = "some-key" + ) + + // When + val result = generateContentRequest.toRequestUrl() + + // Then + assertEquals( + "/v1beta/models/gemini-1.5-flash:generateContent?key=some-key", + result + ) + } + +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentResponseTest.kt b/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentResponseTest.kt new file mode 100644 index 0000000..f3ccf8a --- /dev/null +++ b/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/GenerateContentResponseTest.kt @@ -0,0 +1,52 @@ +package com.tddworks.gemini.api.textGeneration.api + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class GenerateContentResponseTest { + + @Test + fun `should deserialize GenerateContentResponse`() { + // Given + val json = """ + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "some-text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.24741496906413898 + } + ], + "usageMetadata": { + "promptTokenCount": 4, + "candidatesTokenCount": 715, + "totalTokenCount": 719 + }, + "modelVersion": "gemini-1.5-flash" + } + """.trimIndent() + + // When + val response = Json.decodeFromString(json) + + // Then + assertEquals(1, response.candidates.size) + assertEquals("gemini-1.5-flash", response.modelVersion) + assertEquals(4, response.usageMetadata.promptTokenCount) + assertEquals(715, response.usageMetadata.candidatesTokenCount) + assertEquals(719, response.usageMetadata.totalTokenCount) + assertEquals("STOP", response.candidates[0].finishReason) + assertEquals(-0.24741496906413898, response.candidates[0].avgLogprobs) + assertEquals("model", response.candidates[0].content.role) + assertEquals(1, response.candidates[0].content.parts.size) + assertEquals("some-text", response.candidates[0].content.parts[0].text) + } +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/MockHttpClient.kt b/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/MockHttpClient.kt new file mode 100644 index 0000000..06e89cc --- /dev/null +++ b/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/MockHttpClient.kt @@ -0,0 +1,43 @@ +package com.tddworks.gemini.api.textGeneration.api + +import com.tddworks.common.network.api.ktor.internal.JsonLenient +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.* + +/** + * See https://ktor.io/docs/http-client-testing.html#usage + */ +fun mockHttpClient(mockResponse: String) = HttpClient(MockEngine) { + + val headers = + headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) + + install(ContentNegotiation) { + register(ContentType.Application.Json, KotlinxSerializationConverter(JsonLenient)) + } + + engine { + addHandler { request -> + if (request.url.encodedPath == "/v1beta/models/gemini-1.5-flash:generateContent") { + respond(mockResponse, HttpStatusCode.OK, headers) + } else { + error("Unhandled ${request.url.encodedPath}") + } + } + } + + defaultRequest { + url { + protocol = URLProtocol.HTTPS + host = Gemini.HOST + } + + header(HttpHeaders.ContentType, ContentType.Application.Json) + contentType(ContentType.Application.Json) + } +} \ No newline at end of file diff --git a/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/internal/DefaultTextGenerationTest.kt b/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/internal/DefaultTextGenerationTest.kt new file mode 100644 index 0000000..9b1c52c --- /dev/null +++ b/gemini-client/gemini-client-core/src/jvmTest/kotlin/com/tddworks/gemini/api/textGeneration/api/internal/DefaultTextGenerationTest.kt @@ -0,0 +1,79 @@ +package com.tddworks.gemini.api.textGeneration.api.internal + +import com.tddworks.common.network.api.ktor.internal.DefaultHttpRequester +import com.tddworks.common.network.api.ktor.internal.JsonLenient +import com.tddworks.gemini.api.textGeneration.api.Content +import com.tddworks.gemini.api.textGeneration.api.GenerateContentRequest +import com.tddworks.gemini.api.textGeneration.api.Part +import com.tddworks.gemini.api.textGeneration.api.mockHttpClient +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.junit5.KoinTestExtension + +class DefaultTextGenerationTest : KoinTest { + @JvmField + @RegisterExtension + val koinTestExtension = KoinTestExtension.create { + modules( + module { + single { JsonLenient } + }) + } + + @Test + fun `should return correct generateContent response`() = runTest { + // Given + val jsonResponse = """ + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "some-text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.24741496906413898 + } + ], + "usageMetadata": { + "promptTokenCount": 4, + "candidatesTokenCount": 715, + "totalTokenCount": 719 + }, + "modelVersion": "gemini-1.5-flash" + } + """.trimIndent() + val textGenerationApi = DefaultTextGenerationApi( + requester = DefaultHttpRequester( + httpClient = mockHttpClient(jsonResponse) + ) + ) + val request = GenerateContentRequest( + contents = listOf(Content(parts = listOf(Part("some-text")), role = "model")), + ) + + // When + val response = textGenerationApi.generateContent(request) + + // Then + assertEquals("STOP", response.candidates[0].finishReason) + assertEquals(-0.24741496906413898, response.candidates[0].avgLogprobs) + assertEquals("model", response.candidates[0].content.role) + assertEquals(1, response.candidates[0].content.parts.size) + assertEquals("some-text", response.candidates[0].content.parts[0].text) + assertEquals("gemini-1.5-flash", response.modelVersion) + assertEquals(4, response.usageMetadata.promptTokenCount) + assertEquals(715, response.usageMetadata.candidatesTokenCount) + assertEquals(719, response.usageMetadata.totalTokenCount) + } + +} \ No newline at end of file diff --git a/gemini-client/gemini-client-darwin/build.gradle.kts b/gemini-client/gemini-client-darwin/build.gradle.kts new file mode 100644 index 0000000..25eb329 --- /dev/null +++ b/gemini-client/gemini-client-darwin/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + alias(libs.plugins.kotlinx.serialization) + alias(libs.plugins.touchlab.kmmbridge) + alias(libs.plugins.touchlab.skie) + `maven-publish` +} + +kotlin { + listOf( + macosArm64(), + macosX64() + ).forEach { macosTarget -> + macosTarget.binaries.framework { + baseName = "gemini-client-darwin" + export(projects.geminiClient.geminiClientCore) + isStatic = true + } + } + + sourceSets { + commonMain { + dependencies { + api(projects.geminiClient.geminiClientCore) + implementation(libs.ktor.client.darwin) + } + } + appleMain {} + } +} + +addGithubPackagesRepository() // <- Add the GitHub Packages repo + +kmmbridge { + /** + * reference: https://kmmbridge.touchlab.co/docs/artifacts/MAVEN_REPO_ARTIFACTS#github-packages + * 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. + */ + mavenPublishArtifacts() // <- Publish using a Maven repo +// spm(swiftToolVersion = "5.9") +// spm { +// swiftToolsVersion = "5.9" +// platforms { +// iOS("14") +// macOS("13") +// watchOS("7") +// tvOS("14") +// } +// } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 26b1ec2..2ceac9f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -56,4 +56,6 @@ include(":ollama-client") include(":ollama-client:ollama-client-core") include(":ollama-client:ollama-client-darwin") -//include(":gemini-client") +include(":gemini-client") +include(":gemini-client:gemini-client-core") +include(":gemini-client:gemini-client-darwin") \ No newline at end of file