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

+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![],