From 75077108fd622e8aa41f48b9f3aa44f9cbd281f1 Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Mon, 18 Mar 2024 16:57:19 -0400 Subject: [PATCH] Add unit tests to common (#85) --- .../generativeai/common/APIController.kt | 88 +++++--- .../common/GenerativeModelTests.kt | 112 ++++++++++ .../common/StreamingSnapshotTests.kt | 198 ++++++++++++++++++ .../generativeai/common/UnarySnapshotTests.kt | 195 +++++++++++++++++ .../client/generativeai/common/util/kotlin.kt | 35 ++++ .../client/generativeai/common/util/tests.kt | 178 ++++++++++++++++ .../streaming/failure-api-key.txt | 21 ++ .../streaming/failure-empty-content.txt | 1 + .../failure-finish-reason-safety.txt | 2 + .../streaming/failure-http-error.txt | 13 ++ .../streaming/failure-image-rejected.txt | 7 + .../failure-prompt-blocked-safety.txt | 2 + .../failure-recitation-no-content.txt | 6 + .../streaming/failure-unknown-model.txt | 13 ++ .../streaming/success-basic-reply-long.txt | 12 ++ .../streaming/success-basic-reply-short.txt | 2 + .../streaming/success-citations-altname.txt | 12 ++ .../streaming/success-citations.txt | 12 ++ .../streaming/success-quotes-escaped.txt | 7 + .../streaming/success-unknown-enum.txt | 11 + .../golden-files/unary/failure-api-key.json | 21 ++ .../unary/failure-empty-content.json | 28 +++ .../unary/failure-finish-reason-safety.json | 54 +++++ .../unary/failure-http-error.json | 13 ++ .../unary/failure-image-rejected.json | 13 ++ .../unary/failure-invalid-response.json | 14 ++ .../unary/failure-malformed-content.json | 30 +++ .../unary/failure-prompt-blocked-safety.json | 23 ++ .../unary/failure-unknown-model.json | 13 ++ .../failure-unsupported-user-location.json | 13 ++ .../unary/success-basic-reply-long.json | 54 +++++ .../unary/success-basic-reply-short.json | 54 +++++ .../unary/success-citations-altname.json | 70 +++++++ .../golden-files/unary/success-citations.json | 70 +++++++ .../unary/success-quote-reply.json | 54 +++++ .../unary/success-unknown-enum.json | 52 +++++ 36 files changed, 1471 insertions(+), 32 deletions(-) create mode 100644 common/src/test/java/com/google/ai/client/generativeai/common/GenerativeModelTests.kt create mode 100644 common/src/test/java/com/google/ai/client/generativeai/common/StreamingSnapshotTests.kt create mode 100644 common/src/test/java/com/google/ai/client/generativeai/common/UnarySnapshotTests.kt create mode 100644 common/src/test/java/com/google/ai/client/generativeai/common/util/kotlin.kt create mode 100644 common/src/test/java/com/google/ai/client/generativeai/common/util/tests.kt create mode 100644 common/src/test/resources/golden-files/streaming/failure-api-key.txt create mode 100644 common/src/test/resources/golden-files/streaming/failure-empty-content.txt create mode 100644 common/src/test/resources/golden-files/streaming/failure-finish-reason-safety.txt create mode 100644 common/src/test/resources/golden-files/streaming/failure-http-error.txt create mode 100644 common/src/test/resources/golden-files/streaming/failure-image-rejected.txt create mode 100644 common/src/test/resources/golden-files/streaming/failure-prompt-blocked-safety.txt create mode 100644 common/src/test/resources/golden-files/streaming/failure-recitation-no-content.txt create mode 100644 common/src/test/resources/golden-files/streaming/failure-unknown-model.txt create mode 100644 common/src/test/resources/golden-files/streaming/success-basic-reply-long.txt create mode 100644 common/src/test/resources/golden-files/streaming/success-basic-reply-short.txt create mode 100644 common/src/test/resources/golden-files/streaming/success-citations-altname.txt create mode 100644 common/src/test/resources/golden-files/streaming/success-citations.txt create mode 100644 common/src/test/resources/golden-files/streaming/success-quotes-escaped.txt create mode 100644 common/src/test/resources/golden-files/streaming/success-unknown-enum.txt create mode 100644 common/src/test/resources/golden-files/unary/failure-api-key.json create mode 100644 common/src/test/resources/golden-files/unary/failure-empty-content.json create mode 100644 common/src/test/resources/golden-files/unary/failure-finish-reason-safety.json create mode 100644 common/src/test/resources/golden-files/unary/failure-http-error.json create mode 100644 common/src/test/resources/golden-files/unary/failure-image-rejected.json create mode 100644 common/src/test/resources/golden-files/unary/failure-invalid-response.json create mode 100644 common/src/test/resources/golden-files/unary/failure-malformed-content.json create mode 100644 common/src/test/resources/golden-files/unary/failure-prompt-blocked-safety.json create mode 100644 common/src/test/resources/golden-files/unary/failure-unknown-model.json create mode 100644 common/src/test/resources/golden-files/unary/failure-unsupported-user-location.json create mode 100644 common/src/test/resources/golden-files/unary/success-basic-reply-long.json create mode 100644 common/src/test/resources/golden-files/unary/success-basic-reply-short.json create mode 100644 common/src/test/resources/golden-files/unary/success-citations-altname.json create mode 100644 common/src/test/resources/golden-files/unary/success-citations.json create mode 100644 common/src/test/resources/golden-files/unary/success-quote-reply.json create mode 100644 common/src/test/resources/golden-files/unary/success-unknown-enum.json diff --git a/common/src/main/kotlin/com/google/ai/client/generativeai/common/APIController.kt b/common/src/main/kotlin/com/google/ai/client/generativeai/common/APIController.kt index 57e5413a..09daf5e4 100644 --- a/common/src/main/kotlin/com/google/ai/client/generativeai/common/APIController.kt +++ b/common/src/main/kotlin/com/google/ai/client/generativeai/common/APIController.kt @@ -16,6 +16,7 @@ package com.google.ai.client.generativeai.common +import com.google.ai.client.generativeai.common.server.FinishReason import com.google.ai.client.generativeai.common.util.decodeToFlow import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -37,7 +38,9 @@ import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -79,28 +82,39 @@ class APIController( } suspend fun generateContent(request: GenerateContentRequest): GenerateContentResponse = - client - .post("$DOMAIN/${requestOptions.apiVersion}/$model:generateContent") { - applyCommonConfiguration(request) - } - .also { validateResponse(it) } - .body() - - fun generateContentStream(request: GenerateContentRequest): Flow { - return client.postStream( - "$DOMAIN/${requestOptions.apiVersion}/$model:streamGenerateContent?alt=sse" - ) { - applyCommonConfiguration(request) + try { + client + .post("$DOMAIN/${requestOptions.apiVersion}/$model:generateContent") { + applyCommonConfiguration(request) + } + .also { validateResponse(it) } + .body() + .validate() + } catch (e: Throwable) { + throw GoogleGenerativeAIException.from(e) } - } - suspend fun countTokens(request: CountTokensRequest): CountTokensResponse = + fun generateContentStream(request: GenerateContentRequest): Flow = client - .post("$DOMAIN/${requestOptions.apiVersion}/$model:countTokens") { + .postStream( + "$DOMAIN/${requestOptions.apiVersion}/$model:streamGenerateContent?alt=sse" + ) { applyCommonConfiguration(request) } - .also { validateResponse(it) } - .body() + .map { it.validate() } + .catch { throw GoogleGenerativeAIException.from(it) } + + suspend fun countTokens(request: CountTokensRequest): CountTokensResponse = + try { + client + .post("$DOMAIN/${requestOptions.apiVersion}/$model:countTokens") { + applyCommonConfiguration(request) + } + .also { validateResponse(it) } + .body() + } catch (e: Throwable) { + throw GoogleGenerativeAIException.from(e) + } private fun HttpRequestBuilder.applyCommonConfiguration(request: Request) { when (request) { @@ -165,21 +179,31 @@ private inline fun HttpClient.postStream( } private suspend fun validateResponse(response: HttpResponse) { - if (response.status != HttpStatusCode.OK) { - val text = response.bodyAsText() - val message = - try { - JSON.decodeFromString(text).error.message - } catch (e: Throwable) { - "Unexpected Response:\n$text" - } - if (message.contains("API key not valid")) { - throw InvalidAPIKeyException(message) + if (response.status == HttpStatusCode.OK) return + val text = response.bodyAsText() + val message = + try { + JSON.decodeFromString(text).error.message + } catch (e: Throwable) { + "Unexpected Response:\n$text" } - // TODO (b/325117891): Use a better method than string matching. - if (message == "User location is not supported for the API use.") { - throw UnsupportedUserLocationException() - } - throw ServerException(message) + if (message.contains("API key not valid")) { + throw InvalidAPIKeyException(message) + } + // TODO (b/325117891): Use a better method than string matching. + if (message == "User location is not supported for the API use.") { + throw UnsupportedUserLocationException() + } + throw ServerException(message) +} + +private fun GenerateContentResponse.validate() = apply { + if ((candidates?.isEmpty() != false) && promptFeedback == null) { + throw SerializationException("Error deserializing response, found no valid fields") } + promptFeedback?.blockReason?.let { throw PromptBlockedException(this) } + candidates + ?.mapNotNull { it.finishReason } + ?.firstOrNull { it != FinishReason.STOP } + ?.let { throw ResponseStoppedException(this) } } diff --git a/common/src/test/java/com/google/ai/client/generativeai/common/GenerativeModelTests.kt b/common/src/test/java/com/google/ai/client/generativeai/common/GenerativeModelTests.kt new file mode 100644 index 00000000..6269b673 --- /dev/null +++ b/common/src/test/java/com/google/ai/client/generativeai/common/GenerativeModelTests.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.client.generativeai.common + +import com.google.ai.client.generativeai.common.shared.Content +import com.google.ai.client.generativeai.common.shared.TextPart +import com.google.ai.client.generativeai.common.util.commonTest +import com.google.ai.client.generativeai.common.util.createResponses +import com.google.ai.client.generativeai.common.util.doBlocking +import com.google.ai.client.generativeai.common.util.prepareStreamingResponse +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.close +import io.ktor.utils.io.writeFully +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withTimeout +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +internal class GenerativeModelTests { + private val testTimeout = 5.seconds + + @Test + fun `(generateContentStream) emits responses as they come in`() = commonTest { + val response = createResponses("The", " world", " is", " a", " beautiful", " place!") + val bytes = prepareStreamingResponse(response) + + bytes.forEach { channel.writeFully(it) } + val responses = apiController.generateContentStream(textGenerateContentRequest("test")) + + withTimeout(testTimeout) { + responses.collect { + it.candidates?.isEmpty() shouldBe false + channel.close() + } + } + } + + @Test + fun `(generateContent) respects a custom timeout`() = + commonTest(requestOptions = RequestOptions(2.seconds)) { + shouldThrow { + withTimeout(testTimeout) { + apiController.generateContent(textGenerateContentRequest("test")) + } + } + } +} + +@RunWith(Parameterized::class) +internal class ModelNamingTests(private val modelName: String, private val actualName: String) { + + @Test + fun `request should include right model name`() = doBlocking { + val channel = ByteChannel(autoFlush = true) + val mockEngine = MockEngine { + respond(channel, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + prepareStreamingResponse(createResponses("Random")).forEach { channel.writeFully(it) } + val controller = APIController("super_cool_test_key", modelName, RequestOptions(), mockEngine) + + withTimeout(5.seconds) { + controller.generateContentStream(textGenerateContentRequest("cats")).collect { + it.candidates?.isEmpty() shouldBe false + channel.close() + } + } + + mockEngine.requestHistory.first().url.encodedPath shouldContain actualName + } + + companion object { + @JvmStatic + @Parameterized.Parameters + fun data() = + listOf( + arrayOf("gemini-pro", "models/gemini-pro"), + arrayOf("x/gemini-pro", "x/gemini-pro"), + arrayOf("models/gemini-pro", "models/gemini-pro"), + arrayOf("/modelname", "/modelname"), + arrayOf("modifiedNaming/mymodel", "modifiedNaming/mymodel"), + ) + } +} + +fun textGenerateContentRequest(prompt: String) = + GenerateContentRequest( + model = "unused", + contents = listOf(Content(parts = listOf(TextPart(prompt)))) + ) diff --git a/common/src/test/java/com/google/ai/client/generativeai/common/StreamingSnapshotTests.kt b/common/src/test/java/com/google/ai/client/generativeai/common/StreamingSnapshotTests.kt new file mode 100644 index 00000000..7f151320 --- /dev/null +++ b/common/src/test/java/com/google/ai/client/generativeai/common/StreamingSnapshotTests.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.client.generativeai.common + +import com.google.ai.client.generativeai.common.server.BlockReason +import com.google.ai.client.generativeai.common.server.FinishReason +import com.google.ai.client.generativeai.common.shared.HarmCategory +import com.google.ai.client.generativeai.common.shared.TextPart +import com.google.ai.client.generativeai.common.util.goldenStreamingFile +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.ktor.http.HttpStatusCode +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.withTimeout +import org.junit.Test + +internal class StreamingSnapshotTests { + private val testTimeout = 5.seconds + + @Test + fun `short reply`() = + goldenStreamingFile("success-basic-reply-short.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.isEmpty() shouldBe false + responseList.first().candidates?.first()?.finishReason shouldBe FinishReason.STOP + responseList.first().candidates?.first()?.content?.parts?.isEmpty() shouldBe false + responseList.first().candidates?.first()?.safetyRatings?.isEmpty() shouldBe false + } + } + + @Test + fun `long reply`() = + goldenStreamingFile("success-basic-reply-long.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.isEmpty() shouldBe false + responseList.forEach { + it.candidates?.first()?.finishReason shouldBe FinishReason.STOP + it.candidates?.first()?.content?.parts?.isEmpty() shouldBe false + it.candidates?.first()?.safetyRatings?.isEmpty() shouldBe false + } + } + } + + @Test + fun `unknown enum`() = + goldenStreamingFile("success-unknown-enum.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + responses.first { + it.candidates?.any { + it.safetyRatings?.any { it.category == HarmCategory.UNKNOWN } ?: false + } ?: false + } + } + } + + @Test + fun `quotes escaped`() = + goldenStreamingFile("success-quotes-escaped.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + val responseList = responses.toList() + + responseList.isEmpty() shouldBe false + val part = responseList.first().candidates?.first()?.content?.parts?.first() as? TextPart + part.shouldNotBeNull() + part.text shouldContain "\"" + } + } + + @Test + fun `prompt blocked for safety`() = + goldenStreamingFile("failure-prompt-blocked-safety.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response.promptFeedback?.blockReason shouldBe BlockReason.SAFETY + } + } + + @Test + fun `empty content`() = + goldenStreamingFile("failure-empty-content.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `http errors`() = + goldenStreamingFile("failure-http-error.txt", HttpStatusCode.PreconditionFailed) { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `stopped for safety`() = + goldenStreamingFile("failure-finish-reason-safety.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response.candidates?.first()?.finishReason shouldBe FinishReason.SAFETY + } + } + + @Test + fun `citation parsed correctly`() = + goldenStreamingFile("success-citations.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.any { + it.candidates?.any { it.citationMetadata?.citationSources?.isNotEmpty() ?: false } + ?: false + } shouldBe true + } + } + + @Test + fun `citation returns correctly when using alternative name`() = + goldenStreamingFile("success-citations-altname.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + val responseList = responses.toList() + responseList.any { + it.candidates?.any { it.citationMetadata?.citationSources?.isNotEmpty() ?: false } + ?: false + } shouldBe true + } + } + + @Test + fun `stopped for recitation`() = + goldenStreamingFile("failure-recitation-no-content.txt") { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { + val exception = shouldThrow { responses.collect() } + exception.response.candidates?.first()?.finishReason shouldBe FinishReason.RECITATION + } + } + + @Test + fun `image rejected`() = + goldenStreamingFile("failure-image-rejected.txt", HttpStatusCode.BadRequest) { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `unknown model`() = + goldenStreamingFile("failure-unknown-model.txt", HttpStatusCode.NotFound) { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } + + @Test + fun `invalid api key`() = + goldenStreamingFile("failure-api-key.txt", HttpStatusCode.BadRequest) { + val responses = apiController.generateContentStream(textGenerateContentRequest("prompt")) + + withTimeout(testTimeout) { shouldThrow { responses.collect() } } + } +} diff --git a/common/src/test/java/com/google/ai/client/generativeai/common/UnarySnapshotTests.kt b/common/src/test/java/com/google/ai/client/generativeai/common/UnarySnapshotTests.kt new file mode 100644 index 00000000..a05284da --- /dev/null +++ b/common/src/test/java/com/google/ai/client/generativeai/common/UnarySnapshotTests.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.client.generativeai.common + +import com.google.ai.client.generativeai.common.server.BlockReason +import com.google.ai.client.generativeai.common.server.FinishReason +import com.google.ai.client.generativeai.common.shared.HarmCategory +import com.google.ai.client.generativeai.common.util.goldenUnaryFile +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.ktor.http.HttpStatusCode +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withTimeout +import org.junit.Test + +internal class UnarySnapshotTests { + private val testTimeout = 5.seconds + + @Test + fun `short reply`() = + goldenUnaryFile("success-basic-reply-short.json") { + withTimeout(testTimeout) { + val response = apiController.generateContent(textGenerateContentRequest("prompt")) + + response.candidates?.isEmpty() shouldBe false + response.candidates?.first()?.finishReason shouldBe FinishReason.STOP + response.candidates?.first()?.content?.parts?.isEmpty() shouldBe false + response.candidates?.first()?.safetyRatings?.isEmpty() shouldBe false + } + } + + @Test + fun `long reply`() = + goldenUnaryFile("success-basic-reply-long.json") { + withTimeout(testTimeout) { + val response = apiController.generateContent(textGenerateContentRequest("prompt")) + + response.candidates?.isEmpty() shouldBe false + response.candidates?.first()?.finishReason shouldBe FinishReason.STOP + response.candidates?.first()?.content?.parts?.isEmpty() shouldBe false + response.candidates?.first()?.safetyRatings?.isEmpty() shouldBe false + } + } + + @Test + fun `unknown enum`() = + goldenUnaryFile("success-unknown-enum.json") { + withTimeout(testTimeout) { + val response = apiController.generateContent(textGenerateContentRequest("prompt")) + + response.candidates?.first { + it.safetyRatings?.any { it.category == HarmCategory.UNKNOWN } ?: false + } + } + } + + @Test + fun `prompt blocked for safety`() = + goldenUnaryFile("failure-prompt-blocked-safety.json") { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } should { it.response.promptFeedback?.blockReason shouldBe BlockReason.SAFETY } + } + } + + @Test + fun `empty content`() = + goldenUnaryFile("failure-empty-content.json") { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + } + } + + @Test + fun `http error`() = + goldenUnaryFile("failure-http-error.json", HttpStatusCode.PreconditionFailed) { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + } + } + + @Test + fun `user location error`() = + goldenUnaryFile("failure-unsupported-user-location.json", HttpStatusCode.PreconditionFailed) { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + } + } + + @Test + fun `stopped for safety`() = + goldenUnaryFile("failure-finish-reason-safety.json") { + withTimeout(testTimeout) { + val exception = + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + exception.response.candidates?.first()?.finishReason shouldBe FinishReason.SAFETY + } + } + + @Test + fun `citation returns correctly`() = + goldenUnaryFile("success-citations.json") { + withTimeout(testTimeout) { + val response = apiController.generateContent(textGenerateContentRequest("prompt")) + + response.candidates?.isEmpty() shouldBe false + response.candidates?.first()?.citationMetadata?.citationSources?.isNotEmpty() shouldBe true + } + } + + @Test + fun `citation returns correctly when using alternative name`() = + goldenUnaryFile("success-citations-altname.json") { + withTimeout(testTimeout) { + val response = apiController.generateContent(textGenerateContentRequest("prompt")) + + response.candidates?.isEmpty() shouldBe false + response.candidates?.first()?.citationMetadata?.citationSources?.isNotEmpty() shouldBe true + } + } + + @Test + fun `invalid response`() = + goldenUnaryFile("failure-invalid-response.json") { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + } + } + + @Test + fun `malformed content`() = + goldenUnaryFile("failure-malformed-content.json") { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + } + } + + @Test + fun `invalid api key`() = + goldenUnaryFile("failure-api-key.json", HttpStatusCode.BadRequest) { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + } + } + + @Test + fun `image rejected`() = + goldenUnaryFile("failure-image-rejected.json", HttpStatusCode.BadRequest) { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + } + } + + @Test + fun `unknown model`() = + goldenUnaryFile("failure-unknown-model.json", HttpStatusCode.NotFound) { + withTimeout(testTimeout) { + shouldThrow { + apiController.generateContent(textGenerateContentRequest("prompt")) + } + } + } +} diff --git a/common/src/test/java/com/google/ai/client/generativeai/common/util/kotlin.kt b/common/src/test/java/com/google/ai/client/generativeai/common/util/kotlin.kt new file mode 100644 index 00000000..c7ebf102 --- /dev/null +++ b/common/src/test/java/com/google/ai/client/generativeai/common/util/kotlin.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.client.generativeai.common.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking + +/** + * Runs the given [block] using [runBlocking] on the current thread for side effect. + * + * Using this function is like [runBlocking] with default context (which runs the given block on the + * calling thread) but forces the return type to be `Unit`, which is helpful when implementing + * suspending tests as expression functions: + * ``` + * @Test + * fun myTest() = doBlocking {...} + * ``` + */ +internal fun doBlocking(block: suspend CoroutineScope.() -> Unit) { + runBlocking(block = block) +} diff --git a/common/src/test/java/com/google/ai/client/generativeai/common/util/tests.kt b/common/src/test/java/com/google/ai/client/generativeai/common/util/tests.kt new file mode 100644 index 00000000..2655d086 --- /dev/null +++ b/common/src/test/java/com/google/ai/client/generativeai/common/util/tests.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DEPRECATION") // a replacement for our purposes has not been published yet + +package com.google.ai.client.generativeai.common.util + +// import com.google.ai.client.generativeai.internal.util.send +// import com.google.ai.client.generativeai.type.RequestOptions +import com.google.ai.client.generativeai.common.APIController +import com.google.ai.client.generativeai.common.GenerateContentRequest +import com.google.ai.client.generativeai.common.GenerateContentResponse +import com.google.ai.client.generativeai.common.JSON +import com.google.ai.client.generativeai.common.RequestOptions +import com.google.ai.client.generativeai.common.server.Candidate +import com.google.ai.client.generativeai.common.shared.Content +import com.google.ai.client.generativeai.common.shared.TextPart +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.close +import io.ktor.utils.io.writeFully +import java.io.File +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString + +internal fun prepareStreamingResponse(response: List): List = + response.map { "data: ${JSON.encodeToString(it)}$SSE_SEPARATOR".toByteArray() } + +internal fun prepareResponse(response: GenerateContentResponse) = + JSON.encodeToString(response).toByteArray() + +internal fun createRequest(vararg text: String): GenerateContentRequest { + val contents = text.map { Content(parts = listOf(TextPart(it))) } + + return GenerateContentRequest("gemini", contents) +} + +internal fun createResponse(text: String) = createResponses(text).single() + +internal fun createResponses(vararg text: String): List { + val candidates = text.map { Candidate(Content(parts = listOf(TextPart(it)))) } + + return candidates.map { GenerateContentResponse(candidates = listOf(it)) } +} + +/** + * Wrapper around common instances needed in tests. + * + * @param channel A [ByteChannel] for sending responses through the mock HTTP engine + * @param apiController A [APIController] that consumes the [channel] + * @see commonTest + * @see send + */ +internal data class CommonTestScope(val channel: ByteChannel, val apiController: APIController) + +/** A test that runs under a [CommonTestScope]. */ +internal typealias CommonTest = suspend CommonTestScope.() -> Unit + +/** + * Common test block for providing a [CommonTestScope] during tests. + * + * Example usage: + * ``` + * @Test + * fun `(generateContent) generates a proper response`() = commonTest { + * val request = createRequest("say something nice") + * val response = createResponse("The world is a beautiful place!") + * + * channel.send(prepareResponse(response)) + * + * withTimeout(testTimeout) { + * val data = controller.generateContent(request) + * data.candidates.shouldNotBeEmpty() + * } + * } + * ``` + * + * @param status An optional [HttpStatusCode] to return as a response + * @param requestOptions Optional [RequestOptions] to utilize in the underlying controller + * @param block The test contents themselves, with the [CommonTestScope] implicitly provided + * @see CommonTestScope + */ +internal fun commonTest( + status: HttpStatusCode = HttpStatusCode.OK, + requestOptions: RequestOptions = RequestOptions(), + block: CommonTest +) = doBlocking { + val channel = ByteChannel(autoFlush = true) + val mockEngine = MockEngine { + respond(channel, status, headersOf(HttpHeaders.ContentType, "application/json")) + } + val apiController = APIController("super_cool_test_key", "gemini-pro", requestOptions, mockEngine) + CommonTestScope(channel, apiController).block() +} + +/** + * A variant of [commonTest] for performing *streaming-based* snapshot tests. + * + * Loads the *Golden File* and automatically parses the messages from it; providing it to the + * channel. + * + * @param name The name of the *Golden File* to load + * @param httpStatusCode An optional [HttpStatusCode] to return as a response + * @param block The test contents themselves, with a [CommonTestScope] implicitly provided + * @see goldenUnaryFile + */ +internal fun goldenStreamingFile( + name: String, + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + block: CommonTest +) = doBlocking { + val goldenFile = loadGoldenFile("streaming/$name") + val messages = goldenFile.readLines().filter { it.isNotBlank() } + + commonTest(httpStatusCode) { + launch { + for (message in messages) { + channel.writeFully("$message$SSE_SEPARATOR".toByteArray()) + } + channel.close() + } + + block() + } +} + +/** + * A variant of [commonTest] for performing snapshot tests. + * + * Loads the *Golden File* and automatically provides it to the channel. + * + * @param name The name of the *Golden File* to load + * @param httpStatusCode An optional [HttpStatusCode] to return as a response + * @param block The test contents themselves, with a [CommonTestScope] implicitly provided + * @see goldenStreamingFile + */ +internal fun goldenUnaryFile( + name: String, + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + block: CommonTest +) = + commonTest(httpStatusCode) { + val goldenFile = loadGoldenFile("unary/$name") + val message = goldenFile.readText() + + channel.send(message.toByteArray()) + + block() + } + +/** + * Loads a *Golden File* from the resource directory. + * + * Expects golden files to live under `golden-files` in the resource files. + * + * @see goldenUnaryFile + */ +internal fun loadGoldenFile(path: String): File = loadResourceFile("golden-files/$path") + +/** Loads a file from the test resources directory. */ +internal fun loadResourceFile(path: String) = File("src/test/resources/$path") diff --git a/common/src/test/resources/golden-files/streaming/failure-api-key.txt b/common/src/test/resources/golden-files/streaming/failure-api-key.txt new file mode 100644 index 00000000..ecf6f6b5 --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/failure-api-key.txt @@ -0,0 +1,21 @@ +{ + "error": { + "code": 400, + "message": "API key not valid. Please pass a valid API key.", + "status": "INVALID_ARGUMENT", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "API_KEY_INVALID", + "domain": "googleapis.com", + "metadata": { + "service": "generativelanguage.googleapis.com" + } + }, + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "Invalid API key: AIzv00G7VmUCUeC-5OglO3hcXM" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/streaming/failure-empty-content.txt b/common/src/test/resources/golden-files/streaming/failure-empty-content.txt new file mode 100644 index 00000000..5762b515 --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/failure-empty-content.txt @@ -0,0 +1 @@ +data: {"candidates": [{"content": {},"index": 0}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} diff --git a/common/src/test/resources/golden-files/streaming/failure-finish-reason-safety.txt b/common/src/test/resources/golden-files/streaming/failure-finish-reason-safety.txt new file mode 100644 index 00000000..05e09361 --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/failure-finish-reason-safety.txt @@ -0,0 +1,2 @@ +data: {"candidates": [{"content": {"parts": [{"text": ""}],"role": "model"},"finishReason": "SAFETY","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "HIGH"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} + diff --git a/common/src/test/resources/golden-files/streaming/failure-http-error.txt b/common/src/test/resources/golden-files/streaming/failure-http-error.txt new file mode 100644 index 00000000..8c75fd7b --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/failure-http-error.txt @@ -0,0 +1,13 @@ +{ + "error": { + "code": 400, + "message": "$grpcMessage", + "status": "FAILED_PRECONDITION", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::failed_precondition: User location is not supported for the API use. [google.rpc.error_details_ext] { message: \"User location is not supported for the API use.\" }" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/streaming/failure-image-rejected.txt b/common/src/test/resources/golden-files/streaming/failure-image-rejected.txt new file mode 100644 index 00000000..8567086e --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/failure-image-rejected.txt @@ -0,0 +1,7 @@ +{ + "error": { + "code": 400, + "message": "Request contains an invalid argument.", + "status": "INVALID_ARGUMENT" + } +} diff --git a/common/src/test/resources/golden-files/streaming/failure-prompt-blocked-safety.txt b/common/src/test/resources/golden-files/streaming/failure-prompt-blocked-safety.txt new file mode 100644 index 00000000..58c914af --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/failure-prompt-blocked-safety.txt @@ -0,0 +1,2 @@ +data: {"promptFeedback": {"blockReason": "SAFETY","safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "HIGH"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} + diff --git a/common/src/test/resources/golden-files/streaming/failure-recitation-no-content.txt b/common/src/test/resources/golden-files/streaming/failure-recitation-no-content.txt new file mode 100644 index 00000000..6d69b64e --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/failure-recitation-no-content.txt @@ -0,0 +1,6 @@ +data: {"candidates": [{"content": {"parts": [{"text": "PLACEHOLDER"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} + +data: {"candidates": [{"content": {"parts": [{"text": "PLACEHOLDER"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "LOW"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}],"citationMetadata": {"citationSources": [{"startIndex": 30,"endIndex": 179,"uri": "https://example.com","license": ""}]}}]} + +data: {"candidates": [{"finishReason": "RECITATION","index": 0}]} + diff --git a/common/src/test/resources/golden-files/streaming/failure-unknown-model.txt b/common/src/test/resources/golden-files/streaming/failure-unknown-model.txt new file mode 100644 index 00000000..60b3f55c --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/failure-unknown-model.txt @@ -0,0 +1,13 @@ +{ + "error": { + "code": 404, + "message": "models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.", + "status": "NOT_FOUND", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::not_found: models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods. [google.rpc.error_details_ext] { message: \"models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.\" }" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/streaming/success-basic-reply-long.txt b/common/src/test/resources/golden-files/streaming/success-basic-reply-long.txt new file mode 100644 index 00000000..268f75d7 --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/success-basic-reply-long.txt @@ -0,0 +1,12 @@ +data: {"candidates": [{"content": {"parts": [{"text": "**Cats:**\n\n1. **Anatomy and Appearance:**\n - Cats have"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} + +data: {"candidates": [{"content": {"parts": [{"text": " flexible bodies with a long tail, sharp retractable claws, and soft fur.\n - Their eyes are adapted for low-light conditions and have a vertical slit"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": "-like pupil.\n - Cats come in a wide variety of breeds, each with distinct physical characteristics.\n\n2. **Behavior and Personality:**\n - Cats are known for their independence and solitary nature.\n - They are often described as aloof and mysterious, but they can also be affectionate and playful."}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": "\n - Cats are territorial and communicate through body language, vocalizations, and scent marking.\n\n3. **Diet and Nutrition:**\n - Cats are obligate carnivores, meaning they require animal-based protein for survival.\n - Their diet should consist primarily of high-quality cat food that meets their nutritional needs.\n - Cats are prone to obesity, so portion control and regular exercise are important.\n\n4. **Health and Care:**\n - Cats require regular veterinary checkups, vaccinations, and parasite control.\n - They should be brushed regularly to prevent matting and shedding.\n - Providing"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": " a clean litter box and fresh water is essential for their well-being.\n\n5. **Lifespan:**\n - The average lifespan of a cat is 12-15 years, although some cats can live longer with proper care.\n\n**Dogs:**\n\n1. **Anatomy and Appearance:**\n - Dogs have a diverse range of sizes, shapes, and coat types depending on their breed.\n - They have strong jaws with sharp teeth adapted for chewing and tearing.\n - Dogs' ears are typically floppy or erect and can be used to express emotions.\n\n2. **Behavior and Personality:**\n - Dogs are known for their loyalty, companionship, and trainability.\n - They are social animals that thrive on human interaction and form strong bonds with their owners.\n - Dogs communicate through barking, whining, growling, and body language.\n\n3. **Diet and Nutrition:**\n - Dogs are omnivores and can eat a variety of foods, including meat, grains, fruits, and vegetables.\n - Their diet should be balanced and meet their nutritional requirements based on age, size, and activity level.\n - Obesity is a common problem in dogs, so portion control and exercise are important.\n\n4"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": ". **Health and Care:**\n - Dogs require regular veterinary checkups, vaccinations, and parasite control.\n - They should be brushed regularly to maintain a healthy coat and prevent shedding.\n - Providing adequate exercise, mental stimulation, and socialization is essential for their well-being.\n\n5. **Lifespan:**\n - The average lifespan of a dog varies depending on breed, size, and overall health.\n - Smaller breeds tend to live longer than larger breeds, with an average lifespan of 10-15 years."}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + diff --git a/common/src/test/resources/golden-files/streaming/success-basic-reply-short.txt b/common/src/test/resources/golden-files/streaming/success-basic-reply-short.txt new file mode 100644 index 00000000..b3c07628 --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/success-basic-reply-short.txt @@ -0,0 +1,2 @@ +data: {"candidates": [{"content": {"parts": [{"text": "Cheyenne"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} + diff --git a/common/src/test/resources/golden-files/streaming/success-citations-altname.txt b/common/src/test/resources/golden-files/streaming/success-citations-altname.txt new file mode 100644 index 00000000..4c682dc8 --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/success-citations-altname.txt @@ -0,0 +1,12 @@ +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}],"citationMetadata": {"citations": [{"startIndex": 574,"endIndex": 705,"uri": "https://example.com","license": ""},{"startIndex": 899,"endIndex": 1026,"uri": "https://example.com","license": ""}]}}]} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}],"citationMetadata": {"citations": [{"startIndex": 574,"endIndex": 705,"uri": "https://example.com","license": ""},{"startIndex": 899,"endIndex": 1026,"uri": "https://example.com","license": ""}]}}]} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}],"citationMetadata": {"citations": [{"startIndex": 574,"endIndex": 705,"uri": "https://example.com","license": ""},{"startIndex": 899,"endIndex": 1026,"uri": "https://example.com","license": ""}]}}]} + diff --git a/common/src/test/resources/golden-files/streaming/success-citations.txt b/common/src/test/resources/golden-files/streaming/success-citations.txt new file mode 100644 index 00000000..3bb76e3d --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/success-citations.txt @@ -0,0 +1,12 @@ +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}],"citationMetadata": {"citationSources": [{"startIndex": 574,"endIndex": 705,"uri": "https://example.com","license": ""},{"startIndex": 899,"endIndex": 1026,"uri": "https://example.com","license": ""}]}}]} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}],"citationMetadata": {"citationSources": [{"startIndex": 574,"endIndex": 705,"uri": "https://example.com","license": ""},{"startIndex": 899,"endIndex": 1026,"uri": "https://example.com","license": ""}]}}]} + +data: {"candidates": [{"content": {"parts": [{"text": "placeholder"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}],"citationMetadata": {"citationSources": [{"startIndex": 574,"endIndex": 705,"uri": "https://example.com","license": ""},{"startIndex": 899,"endIndex": 1026,"uri": "https://example.com","license": ""}]}}]} + diff --git a/common/src/test/resources/golden-files/streaming/success-quotes-escaped.txt b/common/src/test/resources/golden-files/streaming/success-quotes-escaped.txt new file mode 100644 index 00000000..ef71be29 --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/success-quotes-escaped.txt @@ -0,0 +1,7 @@ +data: {"candidates": [{"content": {"parts": [{"text": " Pineapples and \"bananas\" are two different types of fruit. Pineapples grow on a"}]},"index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_TOXICITY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUAL","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_VIOLENCE","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DEROGATORY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_TOXICITY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUAL","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_VIOLENCE","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DEROGATORY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS","probability": "NEGLIGIBLE"}]}} + +data: {"candidates": [{"content": {"parts": [{"text": " tropical plant with a rosette of long, pointed leaves. Bananas grow on a herbaceous"}]},"index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_TOXICITY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUAL","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_VIOLENCE","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DEROGATORY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": " plant with large, broad leaves. The two plants are not related, and pin"}]},"index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_TOXICITY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUAL","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_VIOLENCE","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DEROGATORY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": "eapples do not grow on banana plants."}]},"index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_TOXICITY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUAL","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_VIOLENCE","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DEROGATORY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS","probability": "NEGLIGIBLE"}]}]} diff --git a/common/src/test/resources/golden-files/streaming/success-unknown-enum.txt b/common/src/test/resources/golden-files/streaming/success-unknown-enum.txt new file mode 100644 index 00000000..0f3da8eb --- /dev/null +++ b/common/src/test/resources/golden-files/streaming/success-unknown-enum.txt @@ -0,0 +1,11 @@ +data: {"candidates": [{"content": {"parts": [{"text": "**Cats:**\n\n- **Physical Characteristics:**\n - Size: Cats come"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}} + +data: {"candidates": [{"content": {"parts": [{"text": " in a wide range of sizes, from small breeds like the Singapura to large breeds like the Maine Coon.\n - Fur: Cats have soft, furry coats"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": " that can vary in length and texture depending on the breed.\n - Eyes: Cats have large, expressive eyes that can be various colors, including green, blue, yellow, and hazel.\n - Ears: Cats have pointed, erect ears that are sensitive to sound.\n - Tail: Cats have long"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": ", flexible tails that they use for balance and communication.\n\n- **Behavior and Personality:**\n - Independent: Cats are often described as independent animals that enjoy spending time alone.\n - Affectionate: Despite their independent nature, cats can be very affectionate and form strong bonds with their owners.\n - Playful: Cats are naturally playful and enjoy engaging in activities such as chasing toys, climbing, and pouncing.\n - Curious: Cats are curious creatures that love to explore their surroundings.\n - Vocal: Cats communicate through a variety of vocalizations, including meows, purrs, hisses, and grow"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": "ls.\n\n- **Health and Care:**\n - Diet: Cats are obligate carnivores, meaning they require animal-based protein for optimal health.\n - Grooming: Cats spend a significant amount of time grooming themselves to keep their fur clean and free of mats.\n - Exercise: Cats need regular exercise to stay healthy and active. This can be achieved through play sessions or access to outdoor space.\n - Veterinary Care: Regular veterinary checkups are essential for maintaining a cat's health and detecting any potential health issues early on.\n\n**Dogs:**\n\n- **Physical Characteristics:**\n - Size: Dogs come in a wide range of sizes, from small breeds like the Chihuahua to giant breeds like the Great Dane.\n - Fur: Dogs have fur coats that can vary in length, texture, and color depending on the breed.\n - Eyes: Dogs have expressive eyes that can be various colors, including brown, blue, green, and hazel.\n - Ears: Dogs have floppy or erect ears that are sensitive to sound.\n - Tail: Dogs have long, wagging tails that they use for communication and expressing emotions.\n\n- **Behavior and Personality:**\n - Loyal: Dogs are known for their loyalty and"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]} + +data: {"candidates": [{"content": {"parts": [{"text": " devotion to their owners.\n - Friendly: Dogs are generally friendly and outgoing animals that enjoy interacting with people and other animals.\n - Playful: Dogs are playful and energetic creatures that love to engage in activities such as fetching, running, and playing with toys.\n - Trainable: Dogs are highly trainable and can learn a variety of commands and tricks.\n - Vocal: Dogs communicate through a variety of vocalizations, including barking, howling, whining, and growling.\n\n- **Health and Care:**\n - Diet: Dogs are omnivores and can eat a variety of foods, including meat, vegetables, and grains.\n - Grooming: Dogs require regular grooming to keep their fur clean and free of mats. The frequency of grooming depends on the breed and coat type.\n - Exercise: Dogs need regular exercise to stay healthy and active. The amount of exercise required varies depending on the breed and age of the dog.\n - Veterinary Care: Regular veterinary checkups are essential for maintaining a dog's health and detecting any potential health issues early on."}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT_NEW_ENUM","probability": "NEGLIGIBLE_UNKNOWN_ENUM"}]}]} diff --git a/common/src/test/resources/golden-files/unary/failure-api-key.json b/common/src/test/resources/golden-files/unary/failure-api-key.json new file mode 100644 index 00000000..ecf6f6b5 --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-api-key.json @@ -0,0 +1,21 @@ +{ + "error": { + "code": 400, + "message": "API key not valid. Please pass a valid API key.", + "status": "INVALID_ARGUMENT", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "API_KEY_INVALID", + "domain": "googleapis.com", + "metadata": { + "service": "generativelanguage.googleapis.com" + } + }, + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "Invalid API key: AIzv00G7VmUCUeC-5OglO3hcXM" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/failure-empty-content.json b/common/src/test/resources/golden-files/unary/failure-empty-content.json new file mode 100644 index 00000000..4e188966 --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-empty-content.json @@ -0,0 +1,28 @@ +{ + "candidates": [ + { + "content": {}, + "index": 0 + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/failure-finish-reason-safety.json b/common/src/test/resources/golden-files/unary/failure-finish-reason-safety.json new file mode 100644 index 00000000..111e33de --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-finish-reason-safety.json @@ -0,0 +1,54 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "" + } + ], + "role": "model" + }, + "finishReason": "SAFETY", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "HIGH" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/failure-http-error.json b/common/src/test/resources/golden-files/unary/failure-http-error.json new file mode 100644 index 00000000..c8b07a5b --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-http-error.json @@ -0,0 +1,13 @@ +{ + "error": { + "code": 400, + "message": "$grpcMessage", + "status": "FAILED_PRECONDITION", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::failed_precondition: User location is not supported for the API use. [google.rpc.error_details_ext] { message: \"User location is not supported for the API use.\" }" + } + ] +} +} diff --git a/common/src/test/resources/golden-files/unary/failure-image-rejected.json b/common/src/test/resources/golden-files/unary/failure-image-rejected.json new file mode 100644 index 00000000..9dacdc71 --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-image-rejected.json @@ -0,0 +1,13 @@ +{ + "error": { + "code": 400, + "message": "Request contains an invalid argument.", + "status": "INVALID_ARGUMENT", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::invalid_argument: invalid status photos.thumbnailer.Status.Code::5: Source image 0 too short" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/failure-invalid-response.json b/common/src/test/resources/golden-files/unary/failure-invalid-response.json new file mode 100644 index 00000000..49d05e18 --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-invalid-response.json @@ -0,0 +1,14 @@ +{ + "this": [ + { + "is": { + "not": [ + { + "a": "valid" + } + ] + }, + "response": {} + } + ] +} diff --git a/common/src/test/resources/golden-files/unary/failure-malformed-content.json b/common/src/test/resources/golden-files/unary/failure-malformed-content.json new file mode 100644 index 00000000..737f2e08 --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-malformed-content.json @@ -0,0 +1,30 @@ +{ + "candidates": [ + { + "content": { + "invalid-field": true + }, + "index": 0 + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/failure-prompt-blocked-safety.json b/common/src/test/resources/golden-files/unary/failure-prompt-blocked-safety.json new file mode 100644 index 00000000..9d2abbb2 --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-prompt-blocked-safety.json @@ -0,0 +1,23 @@ +{ + "promptFeedback": { + "blockReason": "SAFETY", + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "HIGH" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/failure-unknown-model.json b/common/src/test/resources/golden-files/unary/failure-unknown-model.json new file mode 100644 index 00000000..60b3f55c --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-unknown-model.json @@ -0,0 +1,13 @@ +{ + "error": { + "code": 404, + "message": "models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.", + "status": "NOT_FOUND", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::not_found: models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods. [google.rpc.error_details_ext] { message: \"models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.\" }" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/failure-unsupported-user-location.json b/common/src/test/resources/golden-files/unary/failure-unsupported-user-location.json new file mode 100644 index 00000000..c4c2ace4 --- /dev/null +++ b/common/src/test/resources/golden-files/unary/failure-unsupported-user-location.json @@ -0,0 +1,13 @@ +{ + "error": { + "code": 400, + "message": "User location is not supported for the API use.", + "status": "FAILED_PRECONDITION", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::failed_precondition: User location is not supported for the API use. [google.rpc.error_details_ext] { message: \"User location is not supported for the API use.\" }" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/success-basic-reply-long.json b/common/src/test/resources/golden-files/unary/success-basic-reply-long.json new file mode 100644 index 00000000..2ee66177 --- /dev/null +++ b/common/src/test/resources/golden-files/unary/success-basic-reply-long.json @@ -0,0 +1,54 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "1. **Use Freshly Ground Coffee**:\n - Grind your coffee beans just before brewing to preserve their flavor and aroma.\n - Use a burr grinder for a consistent grind size.\n\n2. **Choose the Right Water**:\n - Use filtered or spring water for the best taste.\n - Avoid using tap water, as it may contain impurities that can affect the flavor.\n\n3. **Measure Accurately**:\n - Use a kitchen scale to measure your coffee and water precisely.\n - A general rule of thumb is to use 1:16 ratio of coffee to water (e.g., 15 grams of coffee to 240 grams of water).\n\n4. **Preheat Your Equipment**:\n - Preheat your coffee maker or espresso machine before brewing to ensure a consistent temperature.\n\n5. **Control the Water Temperature**:\n - The ideal water temperature for brewing coffee is between 195°F (90°C) and 205°F (96°C).\n - Too hot water can extract bitter flavors, while too cold water won't extract enough flavor.\n\n6. **Steep the Coffee**:\n - For drip coffee, let the water slowly drip through the coffee grounds for optimal extraction.\n - For espresso, maintain a steady pressure and flow rate during the extraction.\n\n7. **Clean Your Equipment**:\n - Regularly clean your coffee maker or espresso machine to remove any residual oils or coffee grounds that can affect the taste.\n\n8. **Experiment with Different Coffee Beans**:\n - Try different coffee beans from various regions and roasts to find your preferred flavor profile.\n\n9. **Store Coffee Properly**:\n - Store your coffee beans in an airtight container in a cool, dark place to preserve their freshness.\n\n10. **Enjoy Freshly Brewed Coffee**:\n - Drink your coffee as soon as possible after brewing to savor its peak flavor and aroma." + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/success-basic-reply-short.json b/common/src/test/resources/golden-files/unary/success-basic-reply-short.json new file mode 100644 index 00000000..40a9a6da --- /dev/null +++ b/common/src/test/resources/golden-files/unary/success-basic-reply-short.json @@ -0,0 +1,54 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Mountain View, California, United States" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/success-citations-altname.json b/common/src/test/resources/golden-files/unary/success-citations-altname.json new file mode 100644 index 00000000..7adaad5f --- /dev/null +++ b/common/src/test/resources/golden-files/unary/success-citations-altname.json @@ -0,0 +1,70 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "placeholder" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "citationMetadata": { + "citations": [ + { + "startIndex": 574, + "endIndex": 705, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026, + "uri": "https://example.com/", + "license": "" + } + ] + } + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/success-citations.json b/common/src/test/resources/golden-files/unary/success-citations.json new file mode 100644 index 00000000..2a765ace --- /dev/null +++ b/common/src/test/resources/golden-files/unary/success-citations.json @@ -0,0 +1,70 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "placeholder" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "citationMetadata": { + "citationSources": [ + { + "startIndex": 574, + "endIndex": 705, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026, + "uri": "https://example.com/", + "license": "" + } + ] + } + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/success-quote-reply.json b/common/src/test/resources/golden-files/unary/success-quote-reply.json new file mode 100644 index 00000000..f1e5331e --- /dev/null +++ b/common/src/test/resources/golden-files/unary/success-quote-reply.json @@ -0,0 +1,54 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "1. \"The greatest glory in living lies not in never falling, but in rising every time we fall.\" - Nelson Mandela\n2. \"The future belongs to those who believe in the beauty of their dreams.\" - Eleanor Roosevelt\n3. \"It does not matter how slow you go so long as you do not stop.\" - Confucius\n4. \"If you want to live a happy life, tie it to a goal, not to people or things.\" - Albert Einstein\n5. \"The only person you are destined to become is the person you decide to be.\" - Ralph Waldo Emerson\n6. \"It's not how much you have, but how much you enjoy that makes happiness.\" - Charles Spurgeon\n7. \"The greatest wealth is to live content with little.\" - Plato\n8. \"The only way to do great work is to love what you do.\" - Steve Jobs\n9. \"Don't be afraid to fail. Be afraid not to try.\" - Michael Jordan\n10. \"The best way to predict the future is to create it.\" - Abraham Lincoln" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} diff --git a/common/src/test/resources/golden-files/unary/success-unknown-enum.json b/common/src/test/resources/golden-files/unary/success-unknown-enum.json new file mode 100644 index 00000000..b27a11ae --- /dev/null +++ b/common/src/test/resources/golden-files/unary/success-unknown-enum.json @@ -0,0 +1,52 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "1. **Use Freshly Ground Coffee**:\n - Grind your coffee beans just before brewing to preserve their flavor and aroma.\n - Use a burr grinder for a consistent grind size.\n\n\n2. **Choose the Right Water**:\n - Use filtered or spring water for the best taste.\n - Avoid using tap water, as it may contain impurities that can affect the flavor.\n\n\n3. **Measure Accurately**:\n - Use a kitchen scale to measure your coffee and water precisely.\n - A general rule of thumb is to use 1:16 ratio of coffee to water (e.g., 15 grams of coffee to 240 grams of water).\n\n\n4. **Preheat Your Equipment**:\n - Preheat your coffee maker or espresso machine before brewing to ensure a consistent temperature.\n\n\n5. **Control the Water Temperature**:\n - The ideal water temperature for brewing coffee is between 195°F (90°C) and 205°F (96°C).\n - Too hot water can extract bitter flavors, while too cold water won't extract enough flavor.\n\n\n6. **Steep the Coffee**:\n - For drip coffee, let the water slowly drip through the coffee grounds for optimal extraction.\n - For pour-over coffee, pour the water in a circular motion over the coffee grounds, allowing it to steep for 30-45 seconds before continuing.\n\n\n7. **Clean Your Equipment**:\n - Regularly clean your coffee maker or espresso machine to prevent the buildup of oils and residue that can affect the taste of your coffee.\n\n\n8. **Experiment with Different Coffee Beans**:\n - Try different coffee beans from various regions and roasts to find your preferred flavor profile.\n - Experiment with different grind sizes and brewing methods to optimize the flavor of your chosen beans.\n\n\n9. **Store Coffee Properly**:\n - Store your coffee beans in an airtight container in a cool, dark place to preserve their freshness and flavor.\n - Avoid storing coffee in the refrigerator or freezer, as this can cause condensation and affect the taste.\n\n\n10. **Enjoy Freshly Brewed Coffee**:\n - Drink your coffee as soon as possible after brewing to enjoy its peak flavor and aroma.\n - Coffee starts to lose its flavor and aroma within 30 minutes of brewing." + } + ] + }, + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT_ENUM_NEW", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT_LIKE_A_NEW_ENUM", + "probability": "NEGLIGIBLE_NEW_ENUM" + } + ] + } +}