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 @@ -56,16 +56,20 @@ object AiBackend {
* The backend to act on given what is installed, for every caller that has to answer that
* question: the settings selector, the chat's status line, and its availability check.
*
* They must agree, or one launch can route to one backend while the label names another. The
* order is deliberate — the stored choice first, then [DEFAULT_ID], and only then whatever came
* first, which is registration or sort order and so effectively arbitrary.
* They must agree, or one launch can route to one backend while the label names another.
*
* A stored selection is honoured or nothing is: answering "some other installed one" would send
* the prompt, and the attached source with it, to a provider the user did not choose — silently,
* for the consumers that route by [AUTO]. Only when *nothing* is stored is there a choice to
* make, and then it is [DEFAULT_ID] first, then whatever came first; pass [installedIds] in the
* selector's own order, or that last resort answers differently per caller.
*
* @param storedId the persisted selection, or null when nothing has been chosen
* @param installedIds ids of the backends currently registered
* @return the id to act on, or null when nothing is installed
* @param installedIds ids of the backends currently registered, in the selector's order
* @return the id to act on; null when nothing is installed, or when the stored selection is
*/
fun preferredId(storedId: String?, installedIds: Collection<String>): String? =
storedId?.takeIf { it in installedIds }
?: DEFAULT_ID.takeIf { it in installedIds }
?: installedIds.firstOrNull()
fun preferredId(storedId: String?, installedIds: Collection<String>): String? {
if (storedId != null) return storedId.takeIf { it in installedIds }
return DEFAULT_ID.takeIf { it in installedIds } ?: installedIds.firstOrNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ data class BackendOption(
val classLoader: ClassLoader?,
)

/**
* What the stored backend selection resolves to against what is installed right now.
*
* The three cases are three different fixes for the user, so callers that report the selection have
* to tell them apart: configure the chosen backend, install the plugin it came from, or install any
* backend at all.
*/
sealed interface SelectedBackend {

/** The selected backend, installed and registered. */
data class Installed(val option: BackendOption) : SelectedBackend

/** A selection is stored, but no installed backend answers to it. */
data object Missing : SelectedBackend

/** No backend is installed to select. */
data object None : SelectedBackend
}

/**
* AI Core's own view of the backends currently installed: the registry the settings selector, the
* chat's status line and its availability check all read.
Expand Down Expand Up @@ -68,6 +87,22 @@ object BackendRegistry {
return options.firstOrNull { it.id == id }
}

/**
* The selection resolved against what is installed, as the one question a caller has to ask.
*
* Resolved from a single snapshot: asking [preferred] and [selectedId] as two separate
* questions reads the registry and the preference twice, and a backend registering in between
* let the two answers disagree — reporting "not installed" about a backend that just appeared.
*
* @param storedId the persisted selection; defaults to the stored one
* @return which of the three [SelectedBackend] cases holds now
*/
@JvmOverloads
fun selected(storedId: String? = selectedId()): SelectedBackend {
preferred(options(), storedId)?.let { return SelectedBackend.Installed(it) }
return if (storedId != null) SelectedBackend.Missing else SelectedBackend.None
}

/**
* The loader that can instantiate [fragmentClassName], found by asking each backend plugin's
* loader whether it can see the class. Used by [BackendFragmentFactory] to rebuild a restored
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.itsaky.androidide.plugins.aicore.services

import com.itsaky.androidide.plugins.PluginLogger
import com.itsaky.androidide.plugins.aicore.backends.AiBackend
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
import com.itsaky.androidide.plugins.aicore.backends.BackendRegistry
import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.LlmInferenceService.*
import java.util.concurrent.CompletableFuture
Expand Down Expand Up @@ -31,9 +31,19 @@ class LlmInferenceServiceImpl(private val logger: PluginLogger? = null) : LlmInf
backends.remove(backendId)
}

override fun getAvailableBackends(): List<LlmBackend> {
return backends.values.toList()
}
/**
* Every registered backend, in the order the settings selector lists them.
*
* Sorted here rather than left in this map's hash order, so a caller that lists them or takes
* the first one gets the same answer on every launch instead of an arbitrary backend.
*
* @return the registered backends, sorted by display name, tolerating a backend that throws
* from its own accessor by sorting it under its id
*/
override fun getAvailableBackends(): List<LlmBackend> =
backends.entries
.sortedBy { (id, backend) -> runCatching { backend.name }.getOrNull() ?: id }
.map { (_, backend) -> backend }

override fun getBackend(backendId: String): LlmBackend? {
return backends[backendId]
Expand Down Expand Up @@ -162,43 +172,57 @@ class LlmInferenceServiceImpl(private val logger: PluginLogger? = null) : LlmInf
}

/**
* Resolves the backend id a request should run on. An explicit id is returned unchanged
* (so the caller keeps its "not found"/"not available" errors); [AiBackend.AUTO] is
* resolved to the user-selected backend, then to any available backend, so callers can
* defer backend choice to AI Core instead of hardcoding one.
* Resolves the backend id a request should run on. An explicit id is returned unchanged;
* [AiBackend.AUTO] resolves to the selected backend the same way the settings screen and the
* chat's status line resolve it, so all three name one backend.
*
* Availability is deliberately not consulted: stepping past an unready backend to another that
* happens to answer would send the prompt to a provider the user did not choose, so an unready
* selection has to fail here instead.
*
* @param requestedId the id from [LlmConfig.backendId]
* @return the id to route to; for AUTO with nothing available, the selected backend's id
* so the downstream "not available" error stays meaningful
* @return the id to route to, unavailable or not, so the caller's "not found"/"not available"
* error names the backend the user actually selected
*/
private fun effectiveBackendId(requestedId: String): String {
if (requestedId != AiBackend.AUTO) return requestedId
val preferredId = AiBackend.idFromPreference(readSelectedBackendPreference())
return backends[preferredId]?.takeIf { it.isAvailable() }?.getId()
?: backends.values.firstOrNull { it.isAvailable() }?.getId()
?: preferredId
val storedId = getPreferredBackendId()
// Falling back to the stored id keeps the failure naming the backend the user selected,
// rather than the default one, when its plugin is no longer installed.
return AiBackend.preferredId(storedId, installedIdsInSelectorOrder())
?: storedId
?: AiBackend.DEFAULT_ID
}

/**
* The backend the user selected on the Agent settings screen.
* Installed ids in the order the settings selector lists them, which is by display name.
*
* Published so a backend can find out whether it is the active one without reading this
* plugin's preferences — see [LlmInferenceService.getPreferredBackendId].
* With nothing stored yet, [AiBackend.preferredId] falls back to the first id offered, so the
* order decides the answer. Handing it this map's own keys would hand it a hash order, and AUTO
* would route to a backend other than the one the selector and the chat's status line name.
*
* @return the selected backend id, or null when nothing has been chosen yet
* @return every registered id, sorted by display name, tolerating a backend that throws from
* its own accessor by sorting it under its id
*/
override fun getPreferredBackendId(): String? =
readSelectedBackendPreference()?.let(AiBackend::idFromPreference)
private fun installedIdsInSelectorOrder(): List<String> =
backends.entries
.map { (id, backend) -> id to (runCatching { backend.name }.getOrNull() ?: id) }
.sortedBy { (_, displayName) -> displayName }
.map { (id, _) -> id }

/**
* Reads the raw stored selection from this plugin's own preferences.
* The backend the user selected on the Agent settings screen.
*
* Published so a backend can find out whether it is the active one without reading this
* plugin's preferences — see [LlmInferenceService.getPreferredBackendId].
*
* @return the stored preference value, or null when unset or before initialization
* Read through the registry rather than from the preference file directly, so "nothing has been
* chosen" means the same here as it does on the settings screen; resolution turns on that
* distinction now, and reading the file twice let the two answer differently on a blank value.
*
* @return the selected backend id, or null when nothing has been chosen yet
*/
private fun readSelectedBackendPreference(): String? =
AiCorePlugin.getContext()
?.getPluginSharedPreferences(AiBackend.PREFERENCE_FILE)
?.getString(AiBackend.PREFERENCE_KEY, null)
override fun getPreferredBackendId(): String? = BackendRegistry.selectedId()

override fun cancelGeneration() {
currentGeneration?.cancel(true)
Expand Down
Loading
Loading