diff --git a/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml b/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml index 58824d79..0a437809 100644 --- a/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml +++ b/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml @@ -40,7 +40,8 @@ android:value="App Dev for All" /> GeminiPromptConfig?, ) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend, - EmbeddingBackend, WebSearchBackend, ActiveModelReportingBackend { + EmbeddingModelSelectable, WebSearchBackend, ActiveModelReportingBackend { private val scope = CoroutineScope(Dispatchers.IO) @@ -162,6 +162,48 @@ class GeminiBackend( ?.trim()?.takeIf { it.isNotEmpty() } ?: DEFAULT_EMBEDDING_MODEL + /** + * The models the saved key can embed with: those that advertise `embedContent`. A saved model + * the list omits is replaced by its first entry. + * + * Fails rather than answering empty without a key, so the screen offering the choice can say + * why there is no list instead of drawing an empty one. + * + * @return the embedding models in catalog order, or a future completed exceptionally with the + * sentence a refused chat turn shows, or, with no usable key, why none could be read + */ + override fun listEmbeddingModels(): CompletableFuture> { + val keyStamp = storedKeyStamp() + return launchFuture( + describeFailure = { e -> + context.logger.warn("GeminiBackend: listing embedding models failed: ${e.message}") + IOException(formatErrorMessage(e, keyStamp), e) + }, + ) { + val apiKey = readGeminiApiKey() + if (apiKey.isNullOrBlank()) throw ReportedFailure(missingKeyMessage()) + val models = describeCatalog(apiKey).embedding + // Off a retired saved model, as OpenAI does: the live catalog omitting it means a 404. + models.firstOrNull()?.takeIf { getEmbeddingModelId() !in models } + ?.let(::setEmbeddingModelId) + models + } + } + + /** + * Stores [modelId] where [getEmbeddingModelId] reads it. + * + * The plugin watches this key, so writing it is what tells AI Core's listeners; setting the + * current model again writes nothing and so tells nobody. + */ + override fun setEmbeddingModelId(modelId: String) { + val model = modelId.trim() + require(model.isNotEmpty()) { "The embedding model id must not be empty" } + if (model == getEmbeddingModelId()) return + agentPrefs()?.edit()?.putString(GeminiPreferences.KEY_EMBEDDING_MODEL, model)?.apply() + context.logger.info("GeminiBackend: embedding model set to $model") + } + /** * Vector length the API last produced for the configured embedding model. * @@ -183,42 +225,20 @@ class GeminiBackend( * store a partial space */ override fun embed(texts: List): CompletableFuture> { - val future = CompletableFuture>() - if (texts.isEmpty()) { - future.complete(emptyList()) - return future - } - // close() cancels the scope, making launch a silent no-op; fail loudly instead. - if (!scope.isActive) { - future.completeExceptionally(IllegalStateException("Gemini backend is closed")) - return future - } - - val job = scope.launch { - val keyStamp = storedKeyStamp() - try { - val apiKey = readGeminiApiKey() - if (apiKey.isNullOrBlank()) { - // The same refusal a chat turn reports, through the same formatter. - future.completeExceptionally( - IOException(userMessage(GeminiFailure.KeyInvalid)) - ) - return@launch - } - future.complete(embedBatches(texts, apiKey)) - } catch (e: CancellationException) { - future.cancel(true) - throw e - } catch (e: Exception) { + if (texts.isEmpty()) return CompletableFuture.completedFuture(emptyList()) + val keyStamp = storedKeyStamp() + return launchFuture( + describeFailure = { e -> context.logger.error("GeminiBackend: embedding ${texts.size} texts failed", e) // The formatted sentence, not the raw body: this message reaches the user through // whichever consumer asked, exactly as a refused chat turn's does. - future.completeExceptionally(IOException(formatErrorMessage(e, keyStamp), e)) - } + IOException(formatErrorMessage(e, keyStamp), e) + }, + ) { + val apiKey = readGeminiApiKey() + if (apiKey.isNullOrBlank()) throw ReportedFailure(missingKeyMessage()) + embedBatches(texts, apiKey) } - future.cancelJobOnCancel(job) - - return future } /** @@ -670,36 +690,22 @@ class GeminiBackend( * network/API failure, so the caller can fall back to a current-models-only list and * never advertise a dead model. */ - internal fun listCatalog(): CompletableFuture { - val future = CompletableFuture() - // close() cancels the scope, making launch a silent no-op; fail loudly instead, or the - // gateway's blocking get() would sit at "Loading" for its full 60-second timeout. - if (!scope.isActive) { - future.completeExceptionally(IllegalStateException("Gemini backend is closed")) - return future - } - - val job = scope.launch { - try { - val key = readGeminiApiKey() - if (key.isNullOrBlank()) { - context.logger.warn("GeminiBackend: no API key configured; cannot list live models") - future.complete(ModelCatalog.EMPTY) - return@launch - } - future.complete(describeCatalog(key)) - } catch (e: CancellationException) { - future.cancel(true) - throw e - } catch (e: Exception) { + internal fun listCatalog(): CompletableFuture = + // A closed scope fails at once, not after the gateway's blocking 60-second get(). + launchFuture( + describeFailure = { e -> context.logger.error("GeminiBackend: Error in listCatalog", e) - future.completeExceptionally(e) + e + }, + ) { + val key = readGeminiApiKey() + if (key.isNullOrBlank()) { + context.logger.warn("GeminiBackend: no API key configured; cannot list live models") + ModelCatalog.EMPTY + } else { + describeCatalog(key) } } - future.cancelJobOnCancel(job) - - return future - } /** * List the models a caller-supplied [apiKey] can use, instead of the one saved on disk. @@ -713,32 +719,19 @@ class GeminiBackend( * status code out of that message to tell a refused key from an unreachable network */ internal fun listCatalog(apiKey: String): CompletableFuture { - val future = CompletableFuture() val key = apiKey.trim() if (key.isEmpty()) { - future.completeExceptionally(IllegalArgumentException("Gemini API key is blank")) - return future - } - // close() cancels the scope, making launch a silent no-op; fail loudly instead. - if (!scope.isActive) { - future.completeExceptionally(IllegalStateException("Gemini backend is closed")) - return future - } - - val job = scope.launch { - try { - future.complete(describeCatalog(key)) - } catch (e: CancellationException) { - future.cancel(true) - throw e - } catch (e: Exception) { - context.logger.warn("GeminiBackend: candidate key check failed: ${e.message}") - future.completeExceptionally(e) + // Not CompletableFuture.failedFuture: that is API 31, and the host still runs on 28. + return CompletableFuture().apply { + completeExceptionally(IllegalArgumentException("Gemini API key is blank")) } } - future.cancelJobOnCancel(job) - - return future + return launchFuture( + describeFailure = { e -> + context.logger.warn("GeminiBackend: candidate key check failed: ${e.message}") + e + }, + ) { describeCatalog(key) } } /** @@ -1098,6 +1091,65 @@ class GeminiBackend( } } + /** + * Runs [block] on this backend's scope and completes the returned future with its value. + * + * Cancelling the future cancels [block], and a closed backend fails the future at once: after + * close() cancels the scope, launch is a silent no-op that would leave it pending forever. + * + * @param describeFailure turns what [block] threw into what the future fails with; a + * [ReportedFailure] skips it, being already in the user's words + */ + private fun launchFuture( + describeFailure: (Exception) -> Throwable, + block: suspend () -> T, + ): CompletableFuture { + val future = CompletableFuture() + if (!scope.isActive) { + future.completeExceptionally(IllegalStateException("Gemini backend is closed")) + return future + } + + val job = scope.launch { + try { + future.complete(block()) + } catch (e: CancellationException) { + future.cancel(true) + throw e + } catch (e: ReportedFailure) { + future.completeExceptionally(e) + } catch (e: Exception) { + future.completeExceptionally(describeFailure(e)) + } + } + future.cancelJobOnCancel(job) + return future + } + + /** + * Why no key could be used, in words that do not blame a key that was never sent: none saved, + * one this device can no longer decrypt, or a Keystore that did not answer this time. + */ + private fun missingKeyMessage(): String { + val stored = runCatching { + secureApiKeyStore.readAndMigrate(agentPrefs(), GeminiPreferences.KEY_API_KEY) + }.getOrNull() + val res = when (stored) { + KeystoreSecretStore.Stored.Absent -> R.string.gemini_error_key_missing + is KeystoreSecretStore.Stored.Value -> + // Readable now but not a moment ago, or saved blank; only blank lasts. + if (stored.plain.isBlank()) R.string.gemini_error_key_missing + else R.string.msg_api_key_unavailable + KeystoreSecretStore.Stored.Unreadable -> R.string.msg_api_key_unreadable + KeystoreSecretStore.Stored.Unavailable, null -> R.string.msg_api_key_unavailable + } + return try { + context.androidContext.getString(res) + } catch (e: Exception) { + "No usable Gemini API key" + } + } + /** * Turn a failure into one user-facing sentence. * @@ -1181,6 +1233,12 @@ class GeminiBackend( } } +/** + * A failure already worded for the user, which [GeminiBackend]'s future helper passes on as it is + * instead of classifying it again as a transport error. + */ +private class ReportedFailure(message: String) : IOException(message) + /** * Cancel [job] when this future is cancelled by its caller. * diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt index ad3f70ed..8e9ce704 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/ModelCatalog.kt @@ -1,11 +1,8 @@ package com.itsaky.androidide.plugins.aiagentgemini.backend /** - * One key's catalog, split into the models each picker may offer. - * - * Carried as one value because both halves come from one paginated `ListModels` walk, filtered on - * the capability each model declares: fetching them separately would pay for the walk twice and - * let the two pickers describe different snapshots. + * One key's catalog, split by declared capability: chat models for this plugin's settings pane, + * embedding models for Vector Search's. One value because both come from one `ListModels` walk. * * @param chat models that advertise `generateContent` * @param embedding models that advertise `embedContent` diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt index fc1249fb..f838285a 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt @@ -49,11 +49,17 @@ class GeminiPlugin : IPlugin, DocumentationExtension { // Tags for the controls on this backend's settings pane (see GeminiSettingsFragment). const val TOOLTIP_TAG_SETTINGS_GEMINI_KEY = "ai_gemini_key" const val TOOLTIP_TAG_SETTINGS_GEMINI_MODEL = "ai_gemini_model" - const val TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL = "ai_gemini_embedding_model" const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_gemini_get_free_key" - /** The settings that change what [GeminiBackend.isAvailable] or its model name answers. */ - private val WATCHED_KEYS = setOf(GeminiPreferences.KEY_API_KEY, GeminiPreferences.KEY_MODEL) + /** + * The settings that change what [GeminiBackend.isAvailable], its model name or its + * embedding model answers; the last is set from Vector Search, which re-reads on notice. + */ + private val WATCHED_KEYS = setOf( + GeminiPreferences.KEY_API_KEY, + GeminiPreferences.KEY_MODEL, + GeminiPreferences.KEY_EMBEDDING_MODEL, + ) @Volatile private var pluginContext: PluginContext? = null @@ -220,22 +226,6 @@ class GeminiPlugin : IPlugin, DocumentationExtension { current models is shown instead.

""".trimIndent(), ), - PluginTooltipEntry( - tag = TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL, - summary = "Which model turns your code into vectors for semantic search. Never used for chat.", - detail = """ -

Semantic search compares meaning rather than words, which it - does by embedding every chunk of the project with this model. It - is a separate setting because no Gemini model does both: this list - holds the models that advertise embedContent, and the - Model list above holds those that advertise - generateContent.

-

Changing it changes the vector space, so the project is indexed - again from scratch. Vectors from two different models are not - comparable, and mixing them would quietly return worse results - rather than fail.

- """.trimIndent(), - ), PluginTooltipEntry( tag = TOOLTIP_TAG_SETTINGS_GET_KEY, summary = "Opens Google AI Studio in your browser, where API keys are free to create.", diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt index e0eeb829..fcd741c1 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/CatalogResult.kt @@ -9,19 +9,11 @@ package com.itsaky.androidide.plugins.aiagentgemini.settings sealed interface CatalogResult { /** - * The backend answered. Either list may be empty, which for [models] is itself suspicious for - * a valid key. - * - * Both halves come from one paginated walk, split by the method each model declares, so they - * describe the same snapshot of the same key. + * The backend answered. The list may be empty, which is itself suspicious for a valid key. * * @param models the chat-capable models the key can reach - * @param embeddingModels the embedding-capable models the key can reach */ - data class Success( - val models: List, - val embeddingModels: List, - ) : CatalogResult + data class Success(val models: List) : CatalogResult /** No "gemini" backend was resolvable — ai-core or ai-agent-gemini is missing, disabled, * or not yet active. */ diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt index a57c006e..d6f71e22 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiCatalogGateway.kt @@ -96,7 +96,7 @@ class BackendGeminiCatalogGateway( return try { val catalog = future.get(LIST_MODELS_TIMEOUT_SECONDS, TimeUnit.SECONDS) ?: ModelCatalog.EMPTY - CatalogResult.Success(catalog.chat, catalog.embedding) + CatalogResult.Success(catalog.chat) } catch (e: ExecutionException) { // The API failure the backend reported; its message carries the HTTP status. CatalogResult.Failed(e.cause ?: e) diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt index 2a3aed6f..667442fd 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt @@ -20,10 +20,7 @@ import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.annotation.DrawableRes -import androidx.annotation.IdRes -import androidx.annotation.StringRes import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -147,8 +144,7 @@ class GeminiSettingsFragment : Fragment() { view.applyPaneStyling(PANE_STYLE, OUTLINED_BUTTON_IDS) setupApiKeyUi(view) - setupModelPicker(view, chatModelPicker()) - setupModelPicker(view, embeddingModelPicker()) + setupModelPicker(view) setupModelRefresh(view) } @@ -642,59 +638,7 @@ class GeminiSettingsFragment : Fragment() { box.isEndIconCheckable = false } - // --- Model pickers ------------------------------------------------------------------------- - - /** - * Everything one model dropdown needs, so the chat and embedding pickers are one - * implementation rather than two that drift. - * - * @param tooltipTag long-press help shared by the picker's label, field, hint and chevron - * @param helpHint hint shown while no catalog is offered - * @param liveHint hint shown once there is a list to tap - * @param read the currently saved model - * @param write persists a model the user picked - * @param options the catalog to offer - */ - private class ModelPicker( - @IdRes val boxId: Int, - @IdRes val inputId: Int, - @IdRes val labelId: Int, - @IdRes val hintId: Int, - val tooltipTag: String, - @StringRes val helpHint: Int, - @StringRes val liveHint: Int, - val read: () -> String, - val write: (String) -> Unit, - val options: LiveData, - ) - - /** The chat model: what a turn is generated with. */ - private fun chatModelPicker() = ModelPicker( - boxId = R.id.gemini_model_box, - inputId = R.id.gemini_model_input, - labelId = R.id.gemini_model_label, - hintId = R.id.gemini_model_hint_text, - tooltipTag = GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL, - helpHint = R.string.hint_gemini_model_help, - liveHint = R.string.hint_gemini_model_live, - read = viewModel::getGeminiModel, - write = viewModel::saveGeminiModel, - options = viewModel.geminiModels, - ) - - /** The embedding model: what semantic search indexes and queries with. */ - private fun embeddingModelPicker() = ModelPicker( - boxId = R.id.gemini_embedding_model_box, - inputId = R.id.gemini_embedding_model_input, - labelId = R.id.gemini_embedding_model_label, - hintId = R.id.gemini_embedding_model_hint_text, - tooltipTag = GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_EMBEDDING_MODEL, - helpHint = R.string.hint_gemini_embedding_model_help, - liveHint = R.string.hint_gemini_embedding_model_live, - read = viewModel::getGeminiEmbeddingModel, - write = viewModel::saveGeminiEmbeddingModel, - options = viewModel.geminiEmbeddingModels, - ) + // --- Model picker -------------------------------------------------------------------------- /** * One model dropdown, built as the same control the OpenAI pane carries rather than a @@ -704,16 +648,16 @@ class GeminiSettingsFragment : Fragment() { * unlike the OpenAI pane there is no free-text model to type. The field itself is what shows * the model in use, which is why there is no separate "current model" line any more. */ - private fun setupModelPicker(view: View, picker: ModelPicker) { - val modelBox = view.findViewById(picker.boxId) - val modelInput = view.findViewById(picker.inputId) - val modelLabel = view.findViewById(picker.labelId) - val modelHint = view.findViewById(picker.hintId) + private fun setupModelPicker(view: View) { + val modelBox = view.findViewById(R.id.gemini_model_box) + val modelInput = view.findViewById(R.id.gemini_model_input) + val modelLabel = view.findViewById(R.id.gemini_model_label) + val modelHint = view.findViewById(R.id.gemini_model_hint_text) setupDropdownEndIcon(modelBox) listOf(modelLabel, modelInput, modelHint) - .forEach { wireTooltip(it, picker.tooltipTag) } + .forEach { wireTooltip(it, GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL) } // A picker, not a text field: the list is the only way to change it. modelInput.keyListener = null @@ -722,20 +666,20 @@ class GeminiSettingsFragment : Fragment() { // the list holding only the selected entry after a day/night switch. modelInput.isSaveEnabled = false // The suppressing overload throughout: a filtering write would narrow the list. - modelInput.setText(picker.read(), false) + modelInput.setText(viewModel.getGeminiModel(), false) // Tapping anywhere in the field opens the list; the end icon is only a second way in. modelInput.setOnClickListener { modelInput.showDropDown() } modelBox.setEndIconOnClickListener { modelInput.showDropDown() } - wireEndIconTooltip(modelBox, picker.tooltipTag) + wireEndIconTooltip(modelBox, GeminiPlugin.TOOLTIP_TAG_SETTINGS_GEMINI_MODEL) // Only a real pick reaches here, so unlike the Spinner this replaced there is no // programmatic selection to tell apart from a user's. modelInput.setOnItemClickListener { parent, _, position, _ -> val selected = parent.getItemAtPosition(position) as? String - if (selected == null || selected == picker.read()) { + if (selected == null || selected == viewModel.getGeminiModel()) { return@setOnItemClickListener } - picker.write(selected) + viewModel.saveGeminiModel(selected) Toast.makeText( requireContext(), getString(R.string.model_changed, selected), @@ -743,7 +687,7 @@ class GeminiSettingsFragment : Fragment() { ).show() } - picker.options.observe(viewLifecycleOwner) { options -> + viewModel.geminiModels.observe(viewLifecycleOwner) { options -> // Cleared, not left stale: an empty list means there is no catalog to offer, and a // remembered one would suggest models this key may no longer reach. modelInput.setAdapter( @@ -753,25 +697,23 @@ class GeminiSettingsFragment : Fragment() { DropdownAdapter(modelInput.context, options.models) } ) - modelHint.setText(if (options.models.isEmpty()) picker.helpHint else picker.liveHint) + modelHint.setText( + if (options.models.isEmpty()) R.string.hint_gemini_model_help + else R.string.hint_gemini_model_live + ) // Migrate off a retired saved model only for a live catalog, never for the fallback: // the field has to show what will actually be requested. - if (!options.isLive || options.models.contains(picker.read())) { + if (!options.isLive || options.models.contains(viewModel.getGeminiModel())) { return@observe } val migrated = options.models.firstOrNull() ?: return@observe - picker.write(migrated) + viewModel.saveGeminiModel(migrated) modelInput.setText(migrated, false) } } - /** - * The one Refresh button, shared by both pickers since one catalog walk answers them both. - * - * The initial fetch is guarded on the chat picker alone: the two are published together, so a - * non-empty chat list means the fetch has already happened. - */ + /** The Refresh button; a non-empty model list means the initial fetch has already happened. */ private fun setupModelRefresh(view: View) { val refreshButton = view.findViewById