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

+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"
);
}
}