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:
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 },
|
||||
|
||||
Reference in new issue
Block a user