Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -383,4 +383,20 @@ abstract class LlmProviderIntegrationTestBase {
assertNotNull(textPart)
assertTrue(textPart!!.text.contains("sunny"))
}

@Test
fun getAvailableModels_realTest() =
runBlocking {
val models = provider.getAvailableModels(apiKey)
println("Available Models: $models")
assertTrue(models.isNotEmpty())
assertTrue(models.any { it.modelName == modelName })
}

@Test
fun getAvailableModels_invalidApiKeyTest() =
runBlocking {
val models = provider.getAvailableModels("INVALID_KEY")
assertTrue(models.isEmpty())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.example.appfunctions.agent.domain.LlmProvider
import com.example.appfunctions.agent.domain.LlmResponse
import com.example.appfunctions.agent.domain.LlmResponsePart
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
Expand Down Expand Up @@ -51,6 +52,64 @@ class GeminiProviderImpl
private val httpClient: HttpClient,
private val toolConverter: GeminiToolConverter,
) : LlmProvider {
override suspend fun getAvailableModels(apiKey: String?): List<LlmModel> {
if (apiKey.isNullOrBlank()) {
return emptyList()
}

val response: HttpResponse =
try {
httpClient.get(GEMINI_MODELS_URL) {
header(HEADER_API_KEY, apiKey)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch Gemini models: ${e.message}")
return emptyList()
}

val responseBodyText = response.bodyAsText()
if (response.status.value !in 200..299) {
Log.w(
TAG,
"Gemini models API error: ${response.status} - $responseBodyText",
)
return emptyList()
}

return try {
val jsonResponse = Json.parseToJsonElement(responseBodyText).jsonObject
val modelsArray = jsonResponse[KEY_MODELS]?.jsonArray ?: return emptyList()

modelsArray
.mapNotNull { element ->
val modelObj = element.jsonObject
val rawName =
modelObj[KEY_NAME]?.jsonPrimitive?.contentOrNull
?: return@mapNotNull null
val modelId = rawName.removePrefix(MODEL_NAME_PREFIX)
val supportedMethods =
modelObj[KEY_SUPPORTED_GENERATION_METHODS]
?.jsonArray
?.mapNotNull { it.jsonPrimitive.contentOrNull }

// Require "generateContent" to filter out non-generative models (e.g. embeddings).
val supportsGenerateContent =
supportedMethods == null ||
VALUE_GENERATE_CONTENT in supportedMethods

if (modelId.startsWith(GEMINI_MODEL_PREFIX) && supportsGenerateContent) {
LlmModel(LlmProviderName.GEMINI, modelId)
} else {
null
}
}
.distinct()
} catch (e: Exception) {
Log.w(TAG, "Failed to parse Gemini models response", e)
emptyList()
}
}

override suspend fun generateResponse(
previousInteractionId: String?,
input: LlmInput,
Expand Down Expand Up @@ -264,8 +323,16 @@ class GeminiProviderImpl
companion object {
private const val GEMINI_INTERACTIONS_URL =
"https://generativelanguage.googleapis.com/v1beta/interactions"
private const val GEMINI_MODELS_URL =
"https://generativelanguage.googleapis.com/v1beta/models?pageSize=100"
private const val TAG = "GeminiProvider"

private const val KEY_MODELS = "models"
private const val KEY_SUPPORTED_GENERATION_METHODS = "supportedGenerationMethods"
private const val VALUE_GENERATE_CONTENT = "generateContent"
private const val MODEL_NAME_PREFIX = "models/"
private const val GEMINI_MODEL_PREFIX = "gemini-"

private const val KEY_MODEL = "model"
private const val KEY_SERVICE_TIER = "service_tier"
private const val KEY_INPUT = "input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,34 @@
*/
package com.example.appfunctions.agent.data

import androidx.room.TypeConverter
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

/** Enum representing the supported LLM providers. */
enum class LlmProviderName {
GEMINI,
@Serializable
enum class LlmProviderName(val displayName: String) {
GEMINI("Gemini"),
}

/** Enum representing the supported LLM models. */
enum class LlmModel private constructor(val providerName: LlmProviderName, val modelName: String) {
// Stable (GA) models: consistent latency and production rate limits. Prefer these.
GEMINI_3_5_FLASH(LlmProviderName.GEMINI, "gemini-3.5-flash"),
GEMINI_3_1_FLASH_LITE(LlmProviderName.GEMINI, "gemini-3.1-flash-lite"),
/** Represents an LLM model offered by a specific [LlmProviderName]. */
@Serializable
data class LlmModel(
val providerName: LlmProviderName,
val modelName: String,
)

class LlmModelConverter {
private val dbJson =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}

// Preview models: newer capabilities but restrictive rate limits and no latency SLA.
GEMINI_3_1_PRO_PREVIEW(LlmProviderName.GEMINI, "gemini-3.1-pro-preview"),
GEMINI_3_FLASH_PREVIEW(LlmProviderName.GEMINI, "gemini-3-flash-preview"),
GEMINI_3_1_FLASH_LITE_PREVIEW(LlmProviderName.GEMINI, "gemini-3.1-flash-lite-preview"),
;
@TypeConverter
fun fromLlmModel(model: LlmModel): String = dbJson.encodeToString(model)

companion object {
val DEFAULT = GEMINI_3_1_FLASH_LITE
}
@TypeConverter
fun toLlmModel(jsonString: String): LlmModel = dbJson.decodeFromString(jsonString)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ package com.example.appfunctions.agent.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.example.appfunctions.agent.data.LlmModelConverter
import com.example.appfunctions.agent.data.db.dao.ChatDao
import com.example.appfunctions.agent.data.db.entities.MessageAttachmentConverter
import com.example.appfunctions.agent.data.db.entities.MessageEntity
import com.example.appfunctions.agent.data.db.entities.ThreadEntity

@Database(entities = [ThreadEntity::class, MessageEntity::class], version = 3, exportSchema = false)
@TypeConverters(MessageAttachmentConverter::class)
@Database(entities = [ThreadEntity::class, MessageEntity::class], version = 4, exportSchema = false)
@TypeConverters(MessageAttachmentConverter::class, LlmModelConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chatDao(): ChatDao
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,19 @@
package com.example.appfunctions.agent.domain

import androidx.appfunctions.metadata.AppFunctionMetadata
import com.example.appfunctions.agent.data.LlmModel
import com.example.appfunctions.agent.data.ServiceTier

/** Interface for LLM providers to generate responses. */
interface LlmProvider {
/**
* Retrieves the list of available models from this LLM provider.
*
* @param apiKey The API key for the LLM provider, or null/blank if not configured.
* @return The list of available [LlmModel]s supported by this provider.
*/
suspend fun getAvailableModels(apiKey: String?): List<LlmModel> = emptyList()

/**
* Generates a response from the LLM.
*
Expand Down
Loading
Loading