Ask a provider for its models, and let the app update its CLI
The Claude model list was four words in machines.rs, copied into every
machine's config.ron at discovery -- so a model the CLI had gained was
unreachable from the phone, which is how Opus 5.5 came to be invisible.
Every catalog is now read from the provider itself when a picker opens:
Claude Code over its control channel (control_request{subtype:list_models},
the same channel the driver sends set_model down, since the CLI has no
listing command), Codex over app-server, llama.cpp from the GGUFs on its
machine. Echo is the one case config.ron still answers, having nothing to
ask. A row the CLI marks disabled -- a model the installed version is too
old to run -- is dropped rather than offered as a chip that fails.
The reading of each catalog lives in that driver's own module, and
provider_models is one line per driver: core code says which driver to ask
and never what an answer looks like. parse_codex_models moved out for the
same reason.
Beside it, a provider now reports the version of its program and can be
told to update it. The version comes from --version on its own machine,
read from whichever stream carried it but only from a run that exited 0 --
llama-server prints its version to stderr, and so does "command not found".
It is never compared against a latest release, which nothing here can know.
Update runs the driver's own updater, or an updateCommand the config file
names for an install those will not touch; its whole output comes back,
because every install on these machines is package-managed and the
updater's refusal is the sentence worth reading. Nothing on its stdin, so a
password prompt fails rather than hangs.
A catalog that cannot be read no longer fails the whole provider view: that
is exactly the provider somebody came there to update, and refusing the
screen took the version and the Update button away with it.
Verified against a scratch server and on the emulator: the live catalog
(1.06s, disabled row absent), all four providers' versions including the
unknown state, the confirmation and output dialogs, a configured command
returning in 87ms with its stdin closed, and Update correctly disabled for
llama.cpp.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53fc59a946
commit
7371af8e36
14 files changed
+773
-135
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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![],
|
||||
|
||||
Reference in new issue
Block a user