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

+23
View File
@@ -175,6 +175,29 @@ Module-by-module intent is in PLAN.md's "Backend layout".
`wc -c` of the partial against the size HuggingFace published, the sha256
it publishes is what makes a resume safe, and a finished download is not a
state: it is a model, in the list beside the one still going.
**A provider's models are asked for at the moment a picker opens**
(2026-09-23, `machines::provider_models`): Claude Code over its control
channel (`control_request{subtype:list_models}`, the same channel the driver
sends `set_model` down -- the CLI has no listing command), Codex over
app-server, llama.cpp from the GGUFs on its machine. The list used to be four
words in `machines.rs` baked into `config.ron` at discovery, so a model the
CLI gained was unreachable from the phone. Two things fall out of it and are
easy to get wrong again -- **the reading of a catalog lives in its driver's
module** and `provider_models` is a line per driver, because core code knowing
what a Claude answer looks like is the session-type branch this project does
not have; and a row the CLI marks `disabled` is dropped, being a model the
installed version is too old to run.
**A provider reports its version and can be told to update itself**
(2026-09-23, `machines::provider_version` / `update_provider`, drawn in
`ProviderScreen.kt`). The version is `--version` on that machine, taken from
whichever stream carried it but **only from a run that exited 0** -- "command
not found" is on stderr too. It is never compared against a latest release,
which nothing here can know. Update runs `DriverKind::update_args` (`claude
update`, `codex update`) or the `updateCommand` a provider's config entry
names, which is config-file only and deliberately not on any route; 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.
Codex is one persistent `codex app-server --stdio` process per session; its
driver uses native turn steering and interruption, persists the protocol
state and thread id, and reads subscription limits through the same CLI
+33
View File
@@ -85,6 +85,39 @@ a control that silently did nothing).
should keep being served by the ordinary build. A provider of its own is
what that has to be, because a router process, its preset file and a
model's load settings all hang off the provider.
- **A provider's model catalog is asked for, never written down** (2026-09-23).
`GET /machines/{id}/providers/{p}/models` puts the question to the provider
itself at the moment a picker opens: Claude Code over its control channel
(`control_request{subtype:list_models}`), Codex over app-server
(`model/list`), llama.cpp by listing the GGUFs on that machine's disk. Echo
is the one case the config file answers, because there is nothing to ask.
Until this the Claude 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 and one it had lost was still offered — Opus 5.5
is what found it. **What decides which models exist is the installed CLI and
the account**, neither of which this app is a party to, so anything it
remembers about them is a guess going stale. Rows the CLI marks `disabled`
(a model the installed version is too old to run) are dropped rather than
offered, since choosing one is a session that fails at the spawn. The dispatch
is a line per driver in `machines::provider_models` and the reading of each
catalog lives in that driver's own module — core code knows which driver to
ask, never what an answer looks like.
- **A provider says which version it is, and can be told to update** (2026-09-23).
The provider view reports `<program> --version` from the machine it runs on,
as `known` or `unknown` with the reason: the version is read from whichever of
stdout and stderr carried it, but **only from a run that succeeded**, because
"command not found" is also written to stderr and must never be drawn where a
version goes. It is never compared against a latest release — what the latest
is cannot be known from here, and "up to date" would be a claim nobody
checked. Update runs the driver's own updater (`claude update`, `codex
update`), or an `updateCommand` the config file names for an install those
will not touch — config-file only, never a route, so the phone still names no
command. The updater's **whole output** is what comes back rather than a
verdict read out of it: every install on these two machines is package-managed,
and "Claude is managed by a package manager" is the sentence somebody needs.
It runs with nothing on its stdin, so a command that stops to ask for a
password fails instead of waiting for ever, with a ten-minute backstop for one
that waits on something else.
- **Migration code is deleted once the update carrying it is received.** The
providers/hosts migration ran on the one host there is and is gone. A file
in the old shape now fails to parse, which is correct because no such file
@@ -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 },
+57 -2
View File
@@ -94,8 +94,28 @@ pub struct ProviderConfig {
/// Override for the executable, for an install that isn't on PATH.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
/// Models offered on the spawn screen. Free text is always allowed
/// too; this is a shortcut list, not a restriction.
/// What updates this provider's program, for an install its own updater
/// will not touch. Run by `sh -c` on the provider's machine, with nothing
/// on its stdin.
///
/// Config-file only, and deliberately not on any route: the phone must
/// never be able to say what runs on a machine -- the same property that
/// keeps `LLAMA_BUILDS` a directory rather than a path somebody types. So
/// putting a package manager here is a decision made by whoever
/// administers that machine, which is also the only person who knows
/// whether it can run unattended. Unset, the program's own updater is
/// asked ([`DriverKind::update_args`]), which for a packaged install says
/// so rather than doing anything.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_command: Option<String>,
/// Models offered on the spawn screen, for a provider with nothing to ask,
/// which is only echo: every other kind's catalog is read from the provider
/// itself when a picker opens (`machines::provider_models`).
///
/// This said "free text is always allowed too" until 2026-09-23 and it was
/// never true of the phone, whose pickers offer what this answers and
/// nothing else -- so a list written here was the whole restriction, which
/// is what kept a newly released model unreachable.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
/// MCP servers whose tools this provider's sessions can use, on top of
@@ -380,6 +400,37 @@ impl DriverKind {
}
}
/// How to ask this kind's program which version it is, and `None` where
/// there is no program to ask: echo is this backend.
///
/// Reported rather than checked against anything. What the latest release
/// is cannot be known from here without asking a service this app has no
/// business talking to, so the provider view says which version is
/// installed and never that one is out of date -- a claim it would have
/// to guess at.
pub fn version_args(self) -> Option<&'static [&'static str]> {
match self {
Self::ClaudeCli | Self::CodexCli | Self::LlamaCpp => Some(&["--version"]),
Self::Echo => None,
}
}
/// How to ask this kind's program to update itself, and `None` for one
/// that has no updater of its own.
///
/// Both coding CLIs take `update`, and both answer a packaged install by
/// saying to use its package manager rather than by doing something to it
/// -- which is the answer worth showing somebody, and is why this is
/// asked even where it will not succeed. A llama.cpp build came from
/// whoever built it, and echo is this backend: neither has one, and the
/// phone draws the control disabled rather than leaving it out.
pub fn update_args(self) -> Option<&'static [&'static str]> {
match self {
Self::ClaudeCli | Self::CodexCli => Some(&["update"]),
Self::Echo | Self::LlamaCpp => None,
}
}
/// The mode used when a new-session form first selects this kind.
pub fn default_permission_mode(self) -> Option<&'static str> {
match self {
@@ -799,6 +850,7 @@ impl Config {
name: ECHO_PROVIDER.to_string(),
kind: DriverKind::Echo,
command: None,
update_command: None,
models: Vec::new(),
mcp_servers: Vec::new(),
model_settings: BTreeMap::new(),
@@ -909,6 +961,7 @@ mod tests {
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: Some("/usr/bin/claude".to_string()),
update_command: None,
models: Vec::new(),
mcp_servers: Vec::new(),
model_settings: BTreeMap::new(),
@@ -930,6 +983,7 @@ mod tests {
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: None,
update_command: None,
models: vec!["haiku".to_string()],
mcp_servers: Vec::new(),
model_settings: BTreeMap::new(),
@@ -1067,6 +1121,7 @@ sessions: [(
name: "claude-cli".to_string(),
kind: DriverKind::ClaudeCli,
command: Some("/usr/bin/claude".to_string()),
update_command: None,
models: Vec::new(),
mcp_servers: Vec::new(),
model_settings: BTreeMap::new(),
+152 -102
View File
@@ -18,7 +18,6 @@
use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{Value, json};
use crate::config::{DriverKind, ProviderConfig};
use crate::session::transport::{Launch, Transport};
@@ -36,10 +35,6 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
];
/// Models offered for a discovered Claude CLI. A shortcut list for the spawn
/// screen, not a restriction -- the field stays free text.
const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"];
/// Where a machine keeps the llama.cpp builds it has besides the one on its
/// PATH: one directory per build, holding either `llama-server` itself or the
/// `bin/llama-server` that `cmake --install` puts there.
@@ -130,10 +125,11 @@ fn probed(found: &str) -> Vec<ProviderConfig> {
// non-interactive ssh session is not the one a person sees when they
// log in, so "it is on my PATH" is not enough.
command: Some(path.to_string()),
models: match kind {
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
_ => Vec::new(),
},
// Set by hand if at all, and so kept by `update_machine` across a
// re-probe, like the model settings below.
update_command: None,
// Asked for when a picker opens instead -- see `provider_models`.
models: Vec::new(),
mcp_servers: mcp_defaults(kind),
// What a probe cannot know: how this machine's models are loaded
// is configured after the fact, and a re-probe keeps it -- see
@@ -177,7 +173,7 @@ pub struct OfferedModel {
impl OfferedModel {
/// A model whose id is its own name, which is every provider but llama.
fn plain(id: impl Into<String>) -> Self {
pub fn plain(id: impl Into<String>) -> Self {
let id = id.into();
Self {
label: id.clone(),
@@ -208,85 +204,161 @@ pub fn mcp_defaults(kind: DriverKind) -> Vec<crate::config::McpServerConfig> {
/// Models the selected provider currently offers on this machine.
///
/// Codex's catalog is account- and CLI-version-specific, so it is asked at the
/// moment the picker opens rather than copied into `config.ron`. A llama.cpp
/// provider offers the GGUFs on the machine it runs on, through this same call
/// -- there was a second route answering that alone, and it went when this one
/// learned to, because a picker offering a model the spawn screen does not, or
/// naming it differently, is two answers to one question. Other providers
/// retain the shortcut list discovery stored for them.
/// One question with one answer, asked at the moment a picker opens: every
/// catalog here is a property of the machine, the account or the installed
/// version rather than of this app, so a list copied into `config.ron` is one
/// that goes quietly stale. There was a second route answering llama.cpp's
/// alone, and it went when this one learned to -- a picker offering a model
/// the spawn screen does not, or naming it differently, is two answers to one
/// question.
///
/// A line per driver and nothing else: what a catalog *is* belongs to the
/// driver that reads it, and this module knows only which one to ask. A
/// provider with nothing to ask -- echo -- offers the shortcut list its config
/// entry carries, which is the one case the config file is the answer.
pub async fn provider_models(
transport: &Transport,
provider: &ProviderConfig,
models_dir: &std::path::Path,
) -> Result<Vec<OfferedModel>> {
if provider.kind == DriverKind::LlamaCpp {
let dir = crate::models::dir_on(transport, models_dir);
let mut found = crate::models::on_machine(transport, &dir).await?;
// A vision model's projector is a file beside it rather than a model,
// and the session that reads pictures is the one on the model: offered
// here it is a chip that starts a server which cannot load it. It is
// still in the machine's own model list, which is where a file on a
// disk is managed and deleted.
found.retain(|model| {
!crate::session::llama::is_projector(model.key.rsplit('/').next().unwrap_or_default())
});
let labels = crate::models::labels(&found);
return Ok(found
.into_iter()
.zip(labels)
.map(|(model, label)| OfferedModel {
id: model.key,
label,
})
.collect());
match provider.kind {
DriverKind::ClaudeCli => crate::session::claude::list_models(transport, provider).await,
DriverKind::CodexCli => crate::session::codex::list_models(transport, provider).await,
DriverKind::LlamaCpp => crate::session::llama::list_models(transport, models_dir).await,
DriverKind::Echo => Ok(provider.models.iter().map(OfferedModel::plain).collect()),
}
if provider.kind != DriverKind::CodexCli {
return Ok(provider.models.iter().map(OfferedModel::plain).collect());
}
let transport = transport.clone();
let program = provider.program().to_string();
tokio::task::spawn_blocking(move || {
let launch = Launch::new(program, vec!["app-server".into(), "--stdio".into()], None);
let initial = json!({
"id": 1,
"method": "initialize",
"params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}}
});
let requests = [
json!({"method": "initialized"}),
json!({"id": 2, "method": "model/list", "params": {"includeHidden": false, "limit": 100}}),
];
let answer = transport.request_json_blocking(&launch, &initial, &requests, 2)?;
Ok(parse_codex_models(&answer)?
.into_iter()
.map(OfferedModel::plain)
.collect())
})
.await?
}
fn parse_codex_models(answer: &Value) -> Result<Vec<String>> {
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
anyhow::bail!("Codex could not list models: {message}");
}
let entries = answer
.pointer("/result/data")
.and_then(Value::as_array)
.context("Codex returned no model catalog")?;
let mut models = entries
.iter()
.filter(|entry| {
!entry
.get("hidden")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.filter_map(|entry| entry.get("model").and_then(Value::as_str))
/// How long an update is given before it is killed.
///
/// Generous because the work is a download -- the Claude CLI is a couple of
/// hundred megabytes -- and because the machine may be on the far end of a
/// tunnel. It is a backstop rather than a budget: what it is really there for
/// is a command that stopped to ask something, which with no stdin to read is
/// a wait nothing ends.
const UPDATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
/// Which version of its program a provider has, or why that could not be
/// asked.
///
/// Two states rather than an `Option<String>`, because "this machine could not
/// be reached" and "this provider has no version" are different things to draw
/// and an absent string is both. A kind with no program to ask -- echo -- has
/// neither, and reports `None`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "state", content = "text")]
pub enum Version {
Known(String),
Unknown(String),
}
/// Asks a provider's program which version it is.
///
/// Trimmed to its first line: the programs here answer with one, and a program
/// that decides to print its licence after it is not going to be drawn in a
/// row beside a button.
///
/// **Either stream, and only from a run that succeeded.** `llama-server`
/// prints its version to stderr and both CLIs print theirs to stdout, so
/// reading one of them is right for some of the programs this asks -- it drew
/// "Version unknown" over a version that was sitting on the other pipe. What
/// the exit status is for is the case that makes taking stderr dangerous: a
/// program that is not installed also writes to stderr, and "command not
/// found" must not be shown where a version goes.
pub async fn provider_version(transport: &Transport, provider: &ProviderConfig) -> Option<Version> {
let args = provider.kind.version_args()?;
let launch = Launch::new(
provider.program(),
args.iter().map(|a| (*a).to_string()).collect(),
None,
);
Some(
match transport
.capture_with_input(&launch, crate::session::transport::Input::None)
.await
{
Ok(captured) if captured.status.success() => {
let stdout = String::from_utf8_lossy(&captured.stdout);
match first_line(&stdout).or_else(|| first_line(&captured.stderr)) {
Some(line) => Version::Known(line),
// It ran and said nothing, which is not a version and must
// not be drawn as one.
None => Version::Unknown("it printed no version".to_string()),
}
}
Ok(captured) => Version::Unknown(if captured.stderr.is_empty() {
format!("it exited {}", captured.status)
} else {
captured.stderr
}),
Err(err) => Version::Unknown(explain(err).to_string()),
},
)
}
fn first_line(text: &str) -> Option<String> {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
models.dedup();
Ok(models)
}
/// Updates a provider's program on its own machine, and reports what that
/// said.
///
/// The output whole rather than a verdict read out of it: an updater that
/// declines -- "Claude is managed by a package manager" is the answer on every
/// install here -- is saying the one thing somebody needs, and a boolean
/// success would throw it away. So this fails only where the command could not
/// be run at all, and a command that ran and refused is an answer.
///
/// Nothing on its stdin, which is what keeps a command that stops to ask
/// something from waiting for ever; [`UPDATE_TIMEOUT`] is the backstop for one
/// that waits on something else.
pub async fn update_provider(transport: &Transport, provider: &ProviderConfig) -> Result<String> {
let launch = match &provider.update_command {
Some(command) => Launch::new("sh", vec!["-c".to_string(), command.clone()], None),
None => {
let args = provider.kind.update_args().with_context(|| {
format!(
"\"{}\" has no updater of its own -- an `updateCommand` on it in this backend's config file is what updates it",
provider.name,
)
})?;
Launch::new(
provider.program(),
args.iter().map(|a| (*a).to_string()).collect(),
None,
)
}
};
let captured = tokio::time::timeout(
UPDATE_TIMEOUT,
transport.capture_with_input(&launch, crate::session::transport::Input::None),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"it was still going after {} minutes and was stopped. A command that needs a \
password cannot be answered from here.",
UPDATE_TIMEOUT.as_secs() / 60,
)
})?
.map_err(explain)?;
// stderr as well, and after: the CLIs write their progress to one and their
// refusals to the other, and which of them carried the sentence that
// matters is not a thing to guess at.
let mut said = String::from_utf8_lossy(&captured.stdout).trim().to_string();
if !captured.stderr.is_empty() {
if !said.is_empty() {
said.push('\n');
}
said.push_str(&captured.stderr);
}
if said.is_empty() {
said = format!("it said nothing ({})", captured.status);
}
Ok(said)
}
/// Adds what to do to failures whose own wording does not say.
@@ -434,26 +506,4 @@ mod tests {
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].name, "codex-cli");
}
#[test]
fn codex_models_are_the_selectable_non_hidden_catalog_entries() {
let answer = json!({"result": {"data": [
{"model": "gpt-small", "hidden": false},
{"model": "gpt-hidden", "hidden": true},
{"model": "gpt-large", "hidden": false}
]}});
assert_eq!(
parse_codex_models(&answer).unwrap(),
vec!["gpt-small".to_string(), "gpt-large".to_string()]
);
}
#[test]
fn a_failed_codex_catalog_is_not_reported_as_an_empty_one() {
let answer = json!({"error": {"message": "login required"}});
assert_eq!(
parse_codex_models(&answer).unwrap_err().to_string(),
"Codex could not list models: login required"
);
}
}
+1
View File
@@ -433,6 +433,7 @@ mod tests {
};
let provider = ProviderConfig {
name: "claude-cli".to_string(),
update_command: None,
kind: DriverKind::ClaudeCli,
command: Some(cli.display().to_string()),
models: Vec::new(),
+69 -3
View File
@@ -12,6 +12,7 @@
//! POST /machines/{id}/providers/{provider}/settings {maxLoaded} -- null for the default
//! POST /machines/{id}/providers/{provider}/model-settings {model, params} -- how it loads
//! POST /machines/{id}/providers/{provider}/stop end the machine's shared server
//! POST /machines/{id}/providers/{provider}/update update its program on its machine
//! POST /machines/{id}/providers/{provider}/unload {model} -- out of memory, server stays
//! GET /machines/{id}/providers/{provider}/models models that provider offers
//! POST /machines/{id}/providers/{provider}/auth begin provider sign-in
@@ -180,6 +181,13 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
"/machines/{id}/providers/{provider}/settings",
post(set_provider_settings),
)
// Updating the program a provider runs, on its own machine. A POST
// with no body: what runs is the driver's own updater or what the
// config file names, never anything the phone sends.
.route(
"/machines/{id}/providers/{provider}/update",
post(update_provider),
)
.route(
"/machines/{id}/providers/{provider}/model-settings",
post(set_model_settings),
@@ -682,9 +690,27 @@ struct ProviderView {
#[serde(skip_serializing_if = "Option::is_none")]
max_loaded: Option<u32>,
models: Vec<ProviderModel>,
/// Why the catalog could not be read, where it could not be.
///
/// Carried rather than failing the whole answer, because a provider whose
/// program cannot be asked is exactly the one somebody came here to
/// update: refusing the screen took the version and the Update button
/// away at the only moment they were the point.
#[serde(skip_serializing_if = "Option::is_none")]
models_error: Option<String>,
/// The tools this provider's sessions have over and above its own.
#[serde(skip_serializing_if = "Vec::is_empty")]
mcp_servers: Vec<String>,
/// Which version of its program is installed, and `None` for a kind with
/// no program to ask. Never compared against a latest release: what that
/// is cannot be known from here, and "up to date" is not a thing to guess.
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<crate::machines::Version>,
/// Whether anything here can update it -- its own updater, or a command
/// the config file names. The control is drawn either way and disabled
/// when this is false, so that a provider nothing updates says so rather
/// than leaving a reader wondering where the button went.
updatable: bool,
/// Its shared server, or `None` for a provider that has none. The
/// difference matters on screen: "nothing is loaded" and "there is nothing
/// here to load" are not the same sentence.
@@ -725,9 +751,16 @@ async fn provider_view(
let machine = machine_by_id(&manager, &id)?;
let provider = provider_on(&machine, &provider_name)?.clone();
let transport = crate::session::transport::Transport::for_machine(&machine);
let offered = crate::machines::provider_models(&transport, &provider, manager.models_dir())
.await
.map_err(from_machine)?;
// Together rather than one after the other: both are a process started on
// that machine, and on a remote one each is a round trip down the tunnel.
let (offered, version) = tokio::join!(
crate::machines::provider_models(&transport, &provider, manager.models_dir()),
crate::machines::provider_version(&transport, &provider),
);
let (offered, models_error) = match offered {
Ok(offered) => (offered, None),
Err(err) => (Vec::new(), Some(format!("{err:#}"))),
};
let router = manager.router_for(&machine, &provider);
// Asked of the server only when there is one running: a router that is
// down has no opinion about which models are loaded, and inventing
@@ -765,8 +798,11 @@ async fn provider_view(
kind: provider.kind,
command: provider.command,
model_params: provider.kind.model_params(),
version,
updatable: provider.update_command.is_some() || provider.kind.update_args().is_some(),
max_loaded: provider.max_loaded,
models,
models_error,
mcp_servers: provider
.mcp_servers
.iter()
@@ -783,6 +819,36 @@ async fn provider_view(
}))
}
/// What an update said, whether or not it did anything.
///
/// Its output and nothing else. The version it left behind is deliberately not
/// here: the screen refetches the provider once the action finishes, which
/// reads the version anyway, and answering with one as well would be the same
/// fact arriving twice -- two processes started on that machine to ask one
/// question, and two places for it to disagree.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateResult {
/// The updater's own output. Shown as it came: an updater declining to
/// touch a packaged install is saying what to do instead, and a verdict
/// read out of this would drop that sentence.
said: String,
}
/// Updates the program one provider runs, on the machine it runs on.
async fn update_provider(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, provider_name)): UrlPath<(String, String)>,
) -> Result<axum::Json<UpdateResult>, ApiError> {
let machine = machine_by_id(&manager, &id)?;
let provider = provider_on(&machine, &provider_name)?.clone();
let transport = crate::session::transport::Transport::for_machine(&machine);
let said = crate::machines::update_provider(&transport, &provider)
.await
.map_err(from_machine)?;
Ok(axum::Json(UpdateResult { said }))
}
/// What the provider itself takes, as opposed to what one of its models does.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
+113
View File
@@ -63,6 +63,7 @@ use super::process;
use super::subagent::Subagents;
use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig};
use crate::machines::OfferedModel;
use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
/// How much of a failing process's stderr the exit report carries. Enough
@@ -1178,6 +1179,87 @@ fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
}))
}
/// What the Claude CLI on this machine says it can run.
///
/// Its `/model` picker is built from a catalog inside the binary, so which
/// models exist is a property of the installed version and of the account --
/// `opus` resolved to Opus 5 before 2.1.280 and to Opus 5.5 after it, with no
/// change here. Asking is the only way to be right about that, and a list
/// written in this file is wrong from the next release onwards.
///
/// The control channel rather than a flag, because the CLI has no listing
/// command: `list_models` is the same channel the driver already sends
/// `set_model` down, and it answers a process started with nothing to say.
pub async fn list_models(
transport: &Transport,
provider: &ProviderConfig,
) -> Result<Vec<OfferedModel>> {
let transport = transport.clone();
let program = provider.program().to_string();
tokio::task::spawn_blocking(move || {
let args = [
"-p",
"--verbose",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
];
let launch = Launch::new(
program,
args.iter().map(|a| (*a).to_string()).collect(),
None,
);
let request = json!({
"type": "control_request",
"request_id": "models",
"request": {"subtype": "list_models"},
});
let answer = transport.request_json_blocking(&launch, None, &[request], |value| {
value.get("type").and_then(Value::as_str) == Some("control_response")
})?;
parse_models(&answer)
})
.await?
}
fn parse_models(answer: &Value) -> Result<Vec<OfferedModel>> {
if let Some(message) = answer.pointer("/response/error").and_then(Value::as_str) {
anyhow::bail!("the Claude CLI could not list models: {message}");
}
let entries = answer
.pointer("/response/response/models")
.and_then(Value::as_array)
.context("the Claude CLI returned no model catalog")?;
Ok(entries
.iter()
// A row the installed version cannot actually run -- it names the
// version that could. Offered, it is a chip that starts a session and
// fails; the honest place to say a CLI is out of date is the provider
// view, which reports the version and can update it.
.filter(|entry| {
!entry
.get("disabled")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.filter_map(|entry| {
let id = entry.get("value").and_then(Value::as_str)?;
Some(OfferedModel {
id: id.to_string(),
// The CLI's own words for it, which is what its `/model` picker
// shows: two rows can resolve to one model and differ only in
// the window they ask for, and the id is where that is written.
label: entry
.get("displayName")
.and_then(Value::as_str)
.unwrap_or(id)
.to_string(),
})
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1752,4 +1834,35 @@ mod tests {
assert!(forget_resume_token(dir.path()));
assert_eq!(read_resume_token(dir.path()), None);
}
/// The real shape, from 2.1.281 on this machine. The disabled row is what
/// the CLI reports for a model the installed version cannot run yet, and
/// offering it is a chip that fails at the spawn.
#[test]
fn a_claude_model_the_installed_cli_cannot_run_is_not_offered() {
let answer = json!({"type": "control_response", "response": {"subtype": "success", "response": {"models": [
{"value": "default", "resolvedModel": "claude-opus-5-5[1m]", "displayName": "Default (recommended)"},
{"value": "haiku", "resolvedModel": "claude-haiku-4-5-20251001", "displayName": "Haiku"},
{"value": "cc-update-required-1", "displayName": "Opus 5.5 (disabled)", "disabled": true}
]}}});
let offered = parse_models(&answer).unwrap();
assert_eq!(
offered.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
vec!["default", "haiku"]
);
// The CLI's own words, since two rows can name one model and differ
// only in the context window they ask for.
assert_eq!(offered[0].label, "Default (recommended)");
}
#[test]
fn a_failed_claude_catalog_is_not_reported_as_an_empty_one() {
let answer = json!({"type": "control_response", "response": {"error": "not logged in"}});
assert_eq!(
parse_models(&answer).unwrap_err().to_string(),
"the Claude CLI could not list models: not logged in"
);
// A response of a shape nobody here has seen is also not "no models".
assert!(parse_models(&json!({"type": "control_response"})).is_err());
}
}
+82
View File
@@ -28,6 +28,7 @@ use super::process;
use super::subagent::Subagents;
use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig};
use crate::machines::OfferedModel;
use translate::Translator;
const STDIN_FIFO: &str = "codex-stdin.fifo";
@@ -1483,6 +1484,65 @@ fn valid_thread_id(id: &str) -> bool {
.all(|character| character.is_ascii_hexdigit() || character == '-')
}
/// What the Codex CLI on this machine says the account can run.
///
/// Asked at the moment the picker opens rather than copied into `config.ron`:
/// the catalog is account- and CLI-version-specific, so a list written down
/// here is a claim about somebody else's subscription.
pub async fn list_models(
transport: &Transport,
provider: &ProviderConfig,
) -> Result<Vec<OfferedModel>> {
let transport = transport.clone();
let program = provider.program().to_string();
tokio::task::spawn_blocking(move || {
let launch = Launch::new(program, vec!["app-server".into(), "--stdio".into()], None);
let initial = json!({
"id": 1,
"method": "initialize",
"params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}}
});
let requests = [
json!({"method": "initialized"}),
json!({"id": 2, "method": "model/list", "params": {"includeHidden": false, "limit": 100}}),
];
let answer = transport.request_json_blocking(
&launch,
Some(&initial),
&requests,
Transport::answer_to(2),
)?;
Ok(parse_models(&answer)?
.into_iter()
.map(OfferedModel::plain)
.collect())
})
.await?
}
fn parse_models(answer: &Value) -> Result<Vec<String>> {
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
anyhow::bail!("Codex could not list models: {message}");
}
let entries = answer
.pointer("/result/data")
.and_then(Value::as_array)
.context("Codex returned no model catalog")?;
let mut models = entries
.iter()
.filter(|entry| {
!entry
.get("hidden")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.filter_map(|entry| entry.get("model").and_then(Value::as_str))
.map(str::to_string)
.collect::<Vec<_>>();
models.dedup();
Ok(models)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2033,4 +2093,26 @@ mod tests {
assert_eq!(retried["params"]["clientUserMessageId"], "client-message");
assert_eq!(retried["params"]["input"][0]["text"], "do not lose me");
}
#[test]
fn codex_models_are_the_selectable_non_hidden_catalog_entries() {
let answer = json!({"result": {"data": [
{"model": "gpt-small", "hidden": false},
{"model": "gpt-hidden", "hidden": true},
{"model": "gpt-large", "hidden": false}
]}});
assert_eq!(
parse_models(&answer).unwrap(),
vec!["gpt-small".to_string(), "gpt-large".to_string()]
);
}
#[test]
fn a_failed_codex_catalog_is_not_reported_as_an_empty_one() {
let answer = json!({"error": {"message": "login required"}});
assert_eq!(
parse_models(&answer).unwrap_err().to_string(),
"Codex could not list models: login required"
);
}
}
+29
View File
@@ -2124,6 +2124,35 @@ pub struct Model {
mmproj: Option<String>,
}
/// The GGUFs the machine serving this provider has, as models to pick from.
///
/// A file on a disk rather than a catalog somebody publishes, which is why the
/// id and the label differ here and nowhere else: the id is the path the
/// server is pointed at, and the label is the name the file's own metadata
/// gives it.
pub async fn list_models(
transport: &Transport,
models_dir: &Path,
) -> Result<Vec<crate::machines::OfferedModel>> {
let dir = crate::models::dir_on(transport, models_dir);
let mut found = crate::models::on_machine(transport, &dir).await?;
// A vision model's projector is a file beside it rather than a model, and
// the session that reads pictures is the one on the model: offered here it
// is a chip that starts a server which cannot load it. It is still in the
// machine's own model list, which is where a file on a disk is managed and
// deleted.
found.retain(|model| !is_projector(model.key.rsplit('/').next().unwrap_or_default()));
let labels = crate::models::labels(&found);
Ok(found
.into_iter()
.zip(labels)
.map(|(model, label)| crate::machines::OfferedModel {
id: model.key,
label,
})
.collect())
}
/// Whether a file lying beside a model is a multimodal projector for it.
///
/// By name, which is the only thing both sides of this can see cheaply: the
+4
View File
@@ -1068,6 +1068,9 @@ impl SessionManager {
if let Some(old) = machine.provider(&provider.name) {
provider.model_settings = old.model_settings.clone();
provider.max_loaded = old.max_loaded;
// Same reason: a probe finds programs, and this was set
// by hand in the config file.
provider.update_command = old.update_command.clone();
}
}
machine.providers = providers;
@@ -4326,6 +4329,7 @@ mod tests {
Config::echo_provider(),
ProviderConfig {
name: "stand-in".to_string(),
update_command: None,
kind: DriverKind::ClaudeCli,
command: Some(command.to_string_lossy().into_owned()),
models: Vec::new(),
+42 -24
View File
@@ -102,19 +102,35 @@ pub enum Transport {
}
impl Transport {
/// Exchanges newline-delimited JSON requests with a short-lived stdio
/// server. `initial` is written first; after its response arrives,
/// `requests` is written and the response bearing `wanted_id` is returned.
/// Accepts the JSON-RPC answer carrying `id`, for
/// [`Transport::request_json_blocking`]'s `wanted`.
///
/// This is the shape Codex's app-server requires for a usage read: an
/// initialize round trip must finish before the initialized notification
/// and account request are accepted.
/// Named rather than written at each call because both of Codex's stdio
/// reads want it and neither of them is where somebody would look for the
/// other.
pub fn answer_to(id: u64) -> impl Fn(&serde_json::Value) -> bool {
move |value| value.get("id").and_then(serde_json::Value::as_u64) == Some(id)
}
/// Exchanges newline-delimited JSON requests with a short-lived stdio
/// server. `handshake` is written first and its response waited for;
/// `requests` is written after that, and the first response `wanted`
/// accepts is returned.
///
/// The handshake is optional because the two protocols spoken here differ
/// on it rather than in kind: Codex's app-server refuses the initialized
/// notification and everything after it until an initialize round trip has
/// finished, while Claude Code's control channel answers a request written
/// the moment the process starts. `wanted` is a predicate for the same
/// reason -- one addresses its answers by a numeric `id` and the other by
/// a string `request_id`, which is a difference in where the answer's name
/// is written and not in what this does.
pub fn request_json_blocking(
&self,
launch: &Launch,
initial: &serde_json::Value,
handshake: Option<&serde_json::Value>,
requests: &[serde_json::Value],
wanted_id: u64,
wanted: impl Fn(&serde_json::Value) -> bool,
) -> Result<serde_json::Value> {
use std::io::{BufRead, BufReader, Write};
@@ -141,22 +157,24 @@ impl Transport {
.stdout
.take()
.context("the JSON server has no stdout")?;
writeln!(stdin, "{initial}")?;
stdin.flush()?;
let mut reader = BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
anyhow::bail!("the JSON server exited before initialization completed");
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if value.get("id").and_then(serde_json::Value::as_u64)
== initial.get("id").and_then(serde_json::Value::as_u64)
{
break;
if let Some(handshake) = handshake {
writeln!(stdin, "{handshake}")?;
stdin.flush()?;
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
anyhow::bail!("the JSON server exited before initialization completed");
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if value.get("id").and_then(serde_json::Value::as_u64)
== handshake.get("id").and_then(serde_json::Value::as_u64)
{
break;
}
}
}
for request in requests {
@@ -166,12 +184,12 @@ impl Transport {
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
anyhow::bail!("the JSON server exited before answering request {wanted_id}");
anyhow::bail!("the JSON server exited before answering");
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if value.get("id").and_then(serde_json::Value::as_u64) == Some(wanted_id) {
if wanted(&value) {
let _ = child.kill();
let _ = child.wait();
return Ok(value);
+8 -4
View File
@@ -282,10 +282,12 @@ impl UsageProvider for CodexUsage {
serde_json::json!({"method": "initialized"}),
serde_json::json!({"id": 2, "method": "account/rateLimits/read"}),
];
let answer = match self
.transport
.request_json_blocking(&launch, &initialized, &requests, 2)
{
let answer = match self.transport.request_json_blocking(
&launch,
Some(&initialized),
&requests,
Transport::answer_to(2),
) {
Ok(answer) => answer,
Err(err) => {
return vec![self.snapshot(
@@ -1105,6 +1107,7 @@ mod tests {
}),
providers: vec![crate::config::ProviderConfig {
name: "claude-cli".to_string(),
update_command: None,
kind: DriverKind::ClaudeCli,
command: None,
models: vec![],
@@ -1171,6 +1174,7 @@ mod tests {
let mut echo_only = unreachable_machine();
echo_only.providers = vec![crate::config::ProviderConfig {
name: "echo".to_string(),
update_command: None,
kind: DriverKind::Echo,
command: None,
models: vec![],