From 7371af8e36c8fad154fdb6f0d22c455700c84d48 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 23 Sep 2026 18:32:51 -0400 Subject: [PATCH] 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 --- AGENTS.md | 23 ++ PLAN.md | 33 +++ .../src/main/kotlin/com/example/aiapp/Api.kt | 64 +++++ .../com/example/aiapp/ProviderScreen.kt | 96 +++++++ server/src/config.rs | 59 +++- server/src/machines.rs | 254 +++++++++++------- server/src/provider_auth.rs | 1 + server/src/routes.rs | 72 ++++- server/src/session/claude.rs | 113 ++++++++ server/src/session/codex.rs | 82 ++++++ server/src/session/llama/mod.rs | 29 ++ server/src/session/mod.rs | 4 + server/src/session/transport.rs | 66 +++-- server/src/usage.rs | 12 +- 14 files changed, 773 insertions(+), 135 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2522a17..fa027a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/PLAN.md b/PLAN.md index 67216ad..012cd47 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 ` --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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 73a8fff..46eb8af 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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, + /** + * 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, + /** + * 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, 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, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt index 1aa8a48..7ce1935 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderScreen.kt @@ -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(null) } var actionError by remember { mutableStateOf(null) } var confirmingDelete by remember { mutableStateOf(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(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 }, diff --git a/server/src/config.rs b/server/src/config.rs index b5dc850..1de77ca 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -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, - /// 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, + /// 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, /// 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(), diff --git a/server/src/machines.rs b/server/src/machines.rs index 92f26e4..c370e31 100644 --- a/server/src/machines.rs +++ b/server/src/machines.rs @@ -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 { // 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) -> Self { + pub fn plain(id: impl Into) -> Self { let id = id.into(); Self { label: id.clone(), @@ -208,85 +204,161 @@ pub fn mcp_defaults(kind: DriverKind) -> Vec { /// 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> { - 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> { - 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`, 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 { + 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 { + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) .map(str::to_string) - .collect::>(); - 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 { + 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" - ); - } } diff --git a/server/src/provider_auth.rs b/server/src/provider_auth.rs index e17a1d4..f312bdb 100644 --- a/server/src/provider_auth.rs +++ b/server/src/provider_auth.rs @@ -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(), diff --git a/server/src/routes.rs b/server/src/routes.rs index 97a31c3..662627e 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -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) -> 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, models: Vec, + /// 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, /// The tools this provider's sessions have over and above its own. #[serde(skip_serializing_if = "Vec::is_empty")] mcp_servers: Vec, + /// 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, + /// 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>, + UrlPath((id, provider_name)): UrlPath<(String, String)>, +) -> Result, 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)] diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 49ed5e0..7c74d4f 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -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 { })) } +/// 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> { + 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> { + 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!["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()); + } } diff --git a/server/src/session/codex.rs b/server/src/session/codex.rs index 842c308..b3bd224 100644 --- a/server/src/session/codex.rs +++ b/server/src/session/codex.rs @@ -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> { + 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> { + 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::>(); + 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" + ); + } } diff --git a/server/src/session/llama/mod.rs b/server/src/session/llama/mod.rs index c298c54..83486b9 100644 --- a/server/src/session/llama/mod.rs +++ b/server/src/session/llama/mod.rs @@ -2124,6 +2124,35 @@ pub struct Model { mmproj: Option, } +/// 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> { + 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 diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 45ca024..06d701e 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -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(), diff --git a/server/src/session/transport.rs b/server/src/session/transport.rs index 5694cc8..b938244 100644 --- a/server/src/session/transport.rs +++ b/server/src/session/transport.rs @@ -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 { 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::(&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::(&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::(&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); diff --git a/server/src/usage.rs b/server/src/usage.rs index 8f99b11..2392e30 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -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![],