Ask a provider for its models, and let the app update its CLI

The Claude model list was four words in machines.rs, copied into every
machine's config.ron at discovery -- so a model the CLI had gained was
unreachable from the phone, which is how Opus 5.5 came to be invisible.
Every catalog is now read from the provider itself when a picker opens:
Claude Code over its control channel (control_request{subtype:list_models},
the same channel the driver sends set_model down, since the CLI has no
listing command), Codex over app-server, llama.cpp from the GGUFs on its
machine. Echo is the one case config.ron still answers, having nothing to
ask. A row the CLI marks disabled -- a model the installed version is too
old to run -- is dropped rather than offered as a chip that fails.

The reading of each catalog lives in that driver's own module, and
provider_models is one line per driver: core code says which driver to ask
and never what an answer looks like. parse_codex_models moved out for the
same reason.

Beside it, a provider now reports the version of its program and can be
told to update it. The version comes from --version on its own machine,
read from whichever stream carried it but only from a run that exited 0 --
llama-server prints its version to stderr, and so does "command not found".
It is never compared against a latest release, which nothing here can know.
Update runs the driver's own updater, or an updateCommand the config file
names for an install those will not touch; its whole output comes back,
because every install on these machines is package-managed and the
updater's refusal is the sentence worth reading. Nothing on its stdin, so a
password prompt fails rather than hangs.

A catalog that cannot be read no longer fails the whole provider view: that
is exactly the provider somebody came there to update, and refusing the
screen took the version and the Update button away with it.

Verified against a scratch server and on the emulator: the live catalog
(1.06s, disabled row absent), all four providers' versions including the
unknown state, the confirmation and output dialogs, a configured command
returning in 87ms with its stdin closed, and Update correctly disabled for
llama.cpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-23 18:32:51 -04:00
1 parent 53fc59a946
commit 7371af8e36
14 files changed
+773 -135

No files matched your search

@@ -1713,8 +1713,22 @@ data class ProviderView(
val command: String?,
/** What each of this provider's models takes; empty for one that loads no models. */
val modelParams: List<ParamSpec>,
/**
* Which version of its program is installed, or why that could not be asked; null for a kind
* with no program of its own. Never a claim about whether it is the latest — the server does
* not know what the latest is, and neither does this.
*/
val version: ProviderVersion?,
/** Whether anything the backend has can update it. The control is drawn either way. */
val updatable: Boolean,
val maxLoaded: Int?,
val models: List<ProviderModel>,
/**
* Why the catalog could not be read, where it could not be. Carried rather than failing the
* whole screen: a provider whose program cannot be asked is the one somebody came here to
* update, and refusing the screen takes the Update button away with it.
*/
val modelsError: String?,
val mcpServers: List<String>,
val server: ServerState?,
)
@@ -1732,6 +1746,26 @@ data class ProviderModel(
val status: String?,
)
/**
* A provider's installed version, or the reason there isn't one to show.
*
* Two cases rather than a nullable string, because "the machine could not be reached" and "it is
* version 2.1.281" are different things to draw and an absent string is both.
*/
sealed interface ProviderVersion {
data class Known(val text: String) : ProviderVersion
data class Unknown(val why: String) : ProviderVersion
}
/**
* What an update said.
*
* Its output alone: the version afterwards comes from refetching the provider, which the screen
* does anyway once the action finishes, rather than from a second answer that could disagree.
*/
data class UpdateResult(val said: String)
/** The shared server behind a provider: whether it is up, and where the backend reaches it. */
data class ServerState(val running: Boolean, val port: Int?)
@@ -1747,6 +1781,8 @@ fun fetchProvider(settings: ServerSettings, machineId: String, provider: String)
kind = body.getString("kind"),
command = body.optString("command").ifEmpty { null },
modelParams = body.optJSONArray("modelParams")?.mapObjects(::parseParamSpec).orEmpty(),
version = parseProviderVersion(body.optJSONObject("version")),
updatable = body.optBoolean("updatable", false),
maxLoaded = if (body.isNull("maxLoaded")) null else body.optInt("maxLoaded"),
models =
body
@@ -1760,6 +1796,7 @@ fun fetchProvider(settings: ServerSettings, machineId: String, provider: String)
)
}
.orEmpty(),
modelsError = body.optString("modelsError").ifEmpty { null },
mcpServers = body.optJSONArray("mcpServers")?.strings().orEmpty(),
server =
body.optJSONObject("server")?.let { server ->
@@ -1771,6 +1808,33 @@ fun fetchProvider(settings: ServerSettings, machineId: String, provider: String)
)
}
private fun parseProviderVersion(body: JSONObject?): ProviderVersion? {
val text = body?.optString("text").orEmpty()
return when (body?.optString("state")) {
"known" -> ProviderVersion.Known(text)
// An unreadable state is not a version. Saying so is the honest answer; making one up from
// the text beside it would put a sentence where a version number goes.
"unknown" -> ProviderVersion.Unknown(text)
else -> null
}
}
/**
* Updates the program one provider runs, on the machine it runs on.
*
* Long, because the work is a download of a couple of hundred megabytes onto a machine that may be
* at the far end of the tunnel. The backend stops it well before this does.
*/
fun updateProvider(settings: ServerSettings, machineId: String, provider: String): UpdateResult =
requestFromServer(
settings,
providerPath(machineId, provider, "/update"),
method = "POST",
readTimeoutMs = 660000,
) {
UpdateResult(said = it.jsonObject().optString("said"))
}
/** How many models this provider's server keeps loaded at once; null for the default. */
fun setProviderSettings(
settings: ServerSettings,
@@ -1,6 +1,7 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -73,6 +74,10 @@ fun ProviderScreen(
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(null) }
var confirmingUpdate by remember { mutableStateOf(false) }
// What the updater said, kept until it is read: it is the whole point of pressing the button —
// an updater that declines names what to do instead — and there is nowhere else to see it.
var updateSaid by remember { mutableStateOf<String?>(null) }
// The machine's own models and what is being fetched onto it. Only for a provider that serves
// files off that machine's disk -- everything else names its models rather than holding them,
@@ -142,6 +147,34 @@ fun ProviderScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Under the program it is the version of, and above the control that changes it:
// the provider's own binary, as opposed to everything below, which is models. A
// kind with no program to ask draws neither.
view.version?.let { version ->
Text(
when (version) {
is ProviderVersion.Known -> version.text
// Words, not a colour: there is no way to tell a version this app
// failed to read from one that happens to look odd.
is ProviderVersion.Unknown -> "Version unknown — ${version.why}"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Its own row rather than beside the line above, which is a short string
// when the version is known and a sentence wrapping to four lines when it is
// not — and the second is the state this button matters most in.
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// Disabled rather than absent, so a provider nothing here can update says
// so instead of leaving a reader to wonder where the control went.
TextButton(
onClick = { confirmingUpdate = true },
enabled = view.updatable && busy == null,
) {
Text("Update")
}
}
}
Spacer(Modifier.height(12.dp))
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
@@ -183,6 +216,19 @@ fun ProviderScreen(
Spacer(Modifier.height(8.dp))
}
}
// Where the models would be, because that is what is missing. It is not the
// whole screen's failure: the version above it was read, and the control that
// might fix this is up there too.
view.modelsError?.let { failure ->
item("models-failed") {
Text(
"Models unavailable: $failure",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
}
}
machineModels.actionError?.let { failure ->
item("models-error") {
Text(failure, color = MaterialTheme.colorScheme.error)
@@ -278,6 +324,56 @@ fun ProviderScreen(
)
}
if (confirmingUpdate) {
AlertDialog(
onDismissRequest = { confirmingUpdate = false },
title = {
Text("Update ${(state as? LoadState.Loaded)?.value?.name ?: "this provider"}?")
},
text = {
Text(
"The program is updated on ${(state as? LoadState.Loaded)?.value?.machine ?: "its machine"}, " +
"which every session there uses. Sessions already running keep the version they " +
"started with; the next one to start gets the new one. Nothing here can answer a " +
"password prompt, so an update needing one stops rather than waits."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingUpdate = false
act("Updating…") {
updateSaid = updateProvider(settings, machineId, provider).said
}
}
) {
Text("Update")
}
},
dismissButton = {
TextButton(onClick = { confirmingUpdate = false }) { Text("Cancel") }
},
)
}
updateSaid?.let { said ->
AlertDialog(
onDismissRequest = { updateSaid = null },
title = { Text("The updater said") },
text = {
// Its own words, monospace and scrollable: this is a program's output, and the
// sentence that matters is as often "use your package manager" as it is a version.
Text(
said,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.verticalScroll(rememberScrollState()),
)
},
confirmButton = { TextButton(onClick = { updateSaid = null }) { Text("Done") } },
)
}
if (confirmingStop) {
AlertDialog(
onDismissRequest = { confirmingStop = false },