@@ -61,7 +61,7 @@ class GeminiBackend(
6161 private val context : PluginContext ,
6262 private val promptConfig : () -> GeminiPromptConfig ? ,
6363) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend,
64- EmbeddingBackend , WebSearchBackend , ActiveModelReportingBackend {
64+ EmbeddingModelSelectable , WebSearchBackend , ActiveModelReportingBackend {
6565
6666 private val scope = CoroutineScope (Dispatchers .IO )
6767
@@ -162,6 +162,43 @@ class GeminiBackend(
162162 ?.trim()?.takeIf { it.isNotEmpty() }
163163 ? : DEFAULT_EMBEDDING_MODEL
164164
165+ /* *
166+ * The models the saved key can embed with: those that advertise `embedContent`.
167+ *
168+ * Fails rather than answering empty without a key, so the screen offering the choice can say
169+ * why there is no list instead of drawing an empty one.
170+ *
171+ * @return the embedding models in catalog order, or a future completed exceptionally with the
172+ * sentence a refused chat turn shows, or, with no usable key, why none could be read
173+ */
174+ override fun listEmbeddingModels (): CompletableFuture <List <String >> {
175+ val keyStamp = storedKeyStamp()
176+ return launchFuture(
177+ describeFailure = { e ->
178+ context.logger.warn(" GeminiBackend: listing embedding models failed: ${e.message} " )
179+ IOException (formatErrorMessage(e, keyStamp), e)
180+ },
181+ ) {
182+ val apiKey = readGeminiApiKey()
183+ if (apiKey.isNullOrBlank()) throw ReportedFailure (missingKeyMessage())
184+ describeCatalog(apiKey).embedding
185+ }
186+ }
187+
188+ /* *
189+ * Stores [modelId] where [getEmbeddingModelId] reads it.
190+ *
191+ * The plugin watches this key, so writing it is what tells AI Core's listeners; setting the
192+ * current model again writes nothing and so tells nobody.
193+ */
194+ override fun setEmbeddingModelId (modelId : String ) {
195+ val model = modelId.trim()
196+ require(model.isNotEmpty()) { " The embedding model id must not be empty" }
197+ if (model == getEmbeddingModelId()) return
198+ agentPrefs()?.edit()?.putString(GeminiPreferences .KEY_EMBEDDING_MODEL , model)?.apply ()
199+ context.logger.info(" GeminiBackend: embedding model set to $model " )
200+ }
201+
165202 /* *
166203 * Vector length the API last produced for the configured embedding model.
167204 *
@@ -183,42 +220,20 @@ class GeminiBackend(
183220 * store a partial space
184221 */
185222 override fun embed (texts : List <String >): CompletableFuture <List <FloatArray >> {
186- val future = CompletableFuture <List <FloatArray >>()
187- if (texts.isEmpty()) {
188- future.complete(emptyList())
189- return future
190- }
191- // close() cancels the scope, making launch a silent no-op; fail loudly instead.
192- if (! scope.isActive) {
193- future.completeExceptionally(IllegalStateException (" Gemini backend is closed" ))
194- return future
195- }
196-
197- val job = scope.launch {
198- val keyStamp = storedKeyStamp()
199- try {
200- val apiKey = readGeminiApiKey()
201- if (apiKey.isNullOrBlank()) {
202- // The same refusal a chat turn reports, through the same formatter.
203- future.completeExceptionally(
204- IOException (userMessage(GeminiFailure .KeyInvalid ))
205- )
206- return @launch
207- }
208- future.complete(embedBatches(texts, apiKey))
209- } catch (e: CancellationException ) {
210- future.cancel(true )
211- throw e
212- } catch (e: Exception ) {
223+ if (texts.isEmpty()) return CompletableFuture .completedFuture(emptyList())
224+ val keyStamp = storedKeyStamp()
225+ return launchFuture(
226+ describeFailure = { e ->
213227 context.logger.error(" GeminiBackend: embedding ${texts.size} texts failed" , e)
214228 // The formatted sentence, not the raw body: this message reaches the user through
215229 // whichever consumer asked, exactly as a refused chat turn's does.
216- future.completeExceptionally(IOException (formatErrorMessage(e, keyStamp), e))
217- }
230+ IOException (formatErrorMessage(e, keyStamp), e)
231+ },
232+ ) {
233+ val apiKey = readGeminiApiKey()
234+ if (apiKey.isNullOrBlank()) throw ReportedFailure (missingKeyMessage())
235+ embedBatches(texts, apiKey)
218236 }
219- future.cancelJobOnCancel(job)
220-
221- return future
222237 }
223238
224239 /* *
@@ -670,36 +685,22 @@ class GeminiBackend(
670685 * network/API failure, so the caller can fall back to a current-models-only list and
671686 * never advertise a dead model.
672687 */
673- internal fun listCatalog (): CompletableFuture <ModelCatalog > {
674- val future = CompletableFuture <ModelCatalog >()
675- // close() cancels the scope, making launch a silent no-op; fail loudly instead, or the
676- // gateway's blocking get() would sit at "Loading" for its full 60-second timeout.
677- if (! scope.isActive) {
678- future.completeExceptionally(IllegalStateException (" Gemini backend is closed" ))
679- return future
680- }
681-
682- val job = scope.launch {
683- try {
684- val key = readGeminiApiKey()
685- if (key.isNullOrBlank()) {
686- context.logger.warn(" GeminiBackend: no API key configured; cannot list live models" )
687- future.complete(ModelCatalog .EMPTY )
688- return @launch
689- }
690- future.complete(describeCatalog(key))
691- } catch (e: CancellationException ) {
692- future.cancel(true )
693- throw e
694- } catch (e: Exception ) {
688+ internal fun listCatalog (): CompletableFuture <ModelCatalog > =
689+ // A closed scope fails at once, not after the gateway's blocking 60-second get().
690+ launchFuture(
691+ describeFailure = { e ->
695692 context.logger.error(" GeminiBackend: Error in listCatalog" , e)
696- future.completeExceptionally(e)
693+ e
694+ },
695+ ) {
696+ val key = readGeminiApiKey()
697+ if (key.isNullOrBlank()) {
698+ context.logger.warn(" GeminiBackend: no API key configured; cannot list live models" )
699+ ModelCatalog .EMPTY
700+ } else {
701+ describeCatalog(key)
697702 }
698703 }
699- future.cancelJobOnCancel(job)
700-
701- return future
702- }
703704
704705 /* *
705706 * List the models a caller-supplied [apiKey] can use, instead of the one saved on disk.
@@ -713,32 +714,19 @@ class GeminiBackend(
713714 * status code out of that message to tell a refused key from an unreachable network
714715 */
715716 internal fun listCatalog (apiKey : String ): CompletableFuture <ModelCatalog > {
716- val future = CompletableFuture <ModelCatalog >()
717717 val key = apiKey.trim()
718718 if (key.isEmpty()) {
719- future.completeExceptionally(IllegalArgumentException (" Gemini API key is blank" ))
720- return future
721- }
722- // close() cancels the scope, making launch a silent no-op; fail loudly instead.
723- if (! scope.isActive) {
724- future.completeExceptionally(IllegalStateException (" Gemini backend is closed" ))
725- return future
726- }
727-
728- val job = scope.launch {
729- try {
730- future.complete(describeCatalog(key))
731- } catch (e: CancellationException ) {
732- future.cancel(true )
733- throw e
734- } catch (e: Exception ) {
735- context.logger.warn(" GeminiBackend: candidate key check failed: ${e.message} " )
736- future.completeExceptionally(e)
719+ // Not CompletableFuture.failedFuture: that is API 31, and the host still runs on 28.
720+ return CompletableFuture <ModelCatalog >().apply {
721+ completeExceptionally(IllegalArgumentException (" Gemini API key is blank" ))
737722 }
738723 }
739- future.cancelJobOnCancel(job)
740-
741- return future
724+ return launchFuture(
725+ describeFailure = { e ->
726+ context.logger.warn(" GeminiBackend: candidate key check failed: ${e.message} " )
727+ e
728+ },
729+ ) { describeCatalog(key) }
742730 }
743731
744732 /* *
@@ -1098,6 +1086,65 @@ class GeminiBackend(
10981086 }
10991087 }
11001088
1089+ /* *
1090+ * Runs [block] on this backend's scope and completes the returned future with its value.
1091+ *
1092+ * Cancelling the future cancels [block], and a closed backend fails the future at once: after
1093+ * close() cancels the scope, launch is a silent no-op that would leave it pending forever.
1094+ *
1095+ * @param describeFailure turns what [block] threw into what the future fails with; a
1096+ * [ReportedFailure] skips it, being already in the user's words
1097+ */
1098+ private fun <T > launchFuture (
1099+ describeFailure : (Exception ) -> Throwable ,
1100+ block : suspend () -> T ,
1101+ ): CompletableFuture <T > {
1102+ val future = CompletableFuture <T >()
1103+ if (! scope.isActive) {
1104+ future.completeExceptionally(IllegalStateException (" Gemini backend is closed" ))
1105+ return future
1106+ }
1107+
1108+ val job = scope.launch {
1109+ try {
1110+ future.complete(block())
1111+ } catch (e: CancellationException ) {
1112+ future.cancel(true )
1113+ throw e
1114+ } catch (e: ReportedFailure ) {
1115+ future.completeExceptionally(e)
1116+ } catch (e: Exception ) {
1117+ future.completeExceptionally(describeFailure(e))
1118+ }
1119+ }
1120+ future.cancelJobOnCancel(job)
1121+ return future
1122+ }
1123+
1124+ /* *
1125+ * Why no key could be used, in words that do not blame a key that was never sent: none saved,
1126+ * one this device can no longer decrypt, or a Keystore that did not answer this time.
1127+ */
1128+ private fun missingKeyMessage (): String {
1129+ val stored = runCatching {
1130+ secureApiKeyStore.readAndMigrate(agentPrefs(), GeminiPreferences .KEY_API_KEY )
1131+ }.getOrNull()
1132+ val res = when (stored) {
1133+ KeystoreSecretStore .Stored .Absent -> R .string.gemini_error_key_missing
1134+ is KeystoreSecretStore .Stored .Value ->
1135+ // Readable now but not a moment ago, or saved blank; only blank lasts.
1136+ if (stored.plain.isBlank()) R .string.gemini_error_key_missing
1137+ else R .string.msg_api_key_unavailable
1138+ KeystoreSecretStore .Stored .Unreadable -> R .string.msg_api_key_unreadable
1139+ KeystoreSecretStore .Stored .Unavailable , null -> R .string.msg_api_key_unavailable
1140+ }
1141+ return try {
1142+ context.androidContext.getString(res)
1143+ } catch (e: Exception ) {
1144+ " No usable Gemini API key"
1145+ }
1146+ }
1147+
11011148 /* *
11021149 * Turn a failure into one user-facing sentence.
11031150 *
@@ -1181,6 +1228,12 @@ class GeminiBackend(
11811228 }
11821229}
11831230
1231+ /* *
1232+ * A failure already worded for the user, which [GeminiBackend]'s future helper passes on as it is
1233+ * instead of classifying it again as a transport error.
1234+ */
1235+ private class ReportedFailure (message : String ) : IOException(message)
1236+
11841237/* *
11851238 * Cancel [job] when this future is cancelled by its caller.
11861239 *
0 commit comments