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
3 changes: 2 additions & 1 deletion plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
android:value="App Dev for All" />

<!-- 26.41: the first release whose plugin-api carries the capability-tag contract;
this backend reports getActiveModelName() and notifyBackendChanged() (ADFA-6278).
this backend reports getActiveModelName() and notifyBackendChanged() (ADFA-6278), and
implements EmbeddingModelSelectable for Vector Search's settings screen (ADFA-6303).
Do not lower it. -->
<meta-data
android:name="plugin.min_ide_version"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class GeminiBackend(
private val context: PluginContext,
private val promptConfig: () -> GeminiPromptConfig?,
) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend,
EmbeddingBackend, WebSearchBackend, ActiveModelReportingBackend {
EmbeddingModelSelectable, WebSearchBackend, ActiveModelReportingBackend {

private val scope = CoroutineScope(Dispatchers.IO)

Expand Down Expand Up @@ -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<List<String>> {
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.
*
Expand All @@ -183,42 +225,20 @@ class GeminiBackend(
* store a partial space
*/
override fun embed(texts: List<String>): CompletableFuture<List<FloatArray>> {
val future = CompletableFuture<List<FloatArray>>()
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
}

/**
Expand Down Expand Up @@ -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<ModelCatalog> {
val future = CompletableFuture<ModelCatalog>()
// 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<ModelCatalog> =
// 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.
Expand All @@ -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<ModelCatalog> {
val future = CompletableFuture<ModelCatalog>()
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<ModelCatalog>().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) }
}

/**
Expand Down Expand Up @@ -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 <T> launchFuture(
describeFailure: (Exception) -> Throwable,
block: suspend () -> T,
): CompletableFuture<T> {
val future = CompletableFuture<T>()
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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -220,22 +226,6 @@ class GeminiPlugin : IPlugin, DocumentationExtension {
current models is shown instead.</p>
""".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 = """
<p>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 <code>embedContent</code>, and the
<b>Model</b> list above holds those that advertise
<code>generateContent</code>.</p>
<p>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.</p>
""".trimIndent(),
),
PluginTooltipEntry(
tag = TOOLTIP_TAG_SETTINGS_GET_KEY,
summary = "Opens Google AI Studio in your browser, where API keys are free to create.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
val embeddingModels: List<String>,
) : CatalogResult
data class Success(val models: List<String>) : CatalogResult

/** No "gemini" backend was resolvable — ai-core or ai-agent-gemini is missing, disabled,
* or not yet active. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading