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
|
`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
|
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.
|
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
|
Codex is one persistent `codex app-server --stdio` process per session; its
|
||||||
driver uses native turn steering and interruption, persists the protocol
|
driver uses native turn steering and interruption, persists the protocol
|
||||||
state and thread id, and reads subscription limits through the same CLI
|
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
|
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
|
what that has to be, because a router process, its preset file and a
|
||||||
model's load settings all hang off the provider.
|
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
|
- **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
|
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
|
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?,
|
val command: String?,
|
||||||
/** What each of this provider's models takes; empty for one that loads no models. */
|
/** What each of this provider's models takes; empty for one that loads no models. */
|
||||||
val modelParams: List<ParamSpec>,
|
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 maxLoaded: Int?,
|
||||||
val models: List<ProviderModel>,
|
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 mcpServers: List<String>,
|
||||||
val server: ServerState?,
|
val server: ServerState?,
|
||||||
)
|
)
|
||||||
@@ -1732,6 +1746,26 @@ data class ProviderModel(
|
|||||||
val status: String?,
|
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. */
|
/** 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?)
|
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"),
|
kind = body.getString("kind"),
|
||||||
command = body.optString("command").ifEmpty { null },
|
command = body.optString("command").ifEmpty { null },
|
||||||
modelParams = body.optJSONArray("modelParams")?.mapObjects(::parseParamSpec).orEmpty(),
|
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"),
|
maxLoaded = if (body.isNull("maxLoaded")) null else body.optInt("maxLoaded"),
|
||||||
models =
|
models =
|
||||||
body
|
body
|
||||||
@@ -1760,6 +1796,7 @@ fun fetchProvider(settings: ServerSettings, machineId: String, provider: String)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
|
modelsError = body.optString("modelsError").ifEmpty { null },
|
||||||
mcpServers = body.optJSONArray("mcpServers")?.strings().orEmpty(),
|
mcpServers = body.optJSONArray("mcpServers")?.strings().orEmpty(),
|
||||||
server =
|
server =
|
||||||
body.optJSONObject("server")?.let { 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. */
|
/** How many models this provider's server keeps loaded at once; null for the default. */
|
||||||
fun setProviderSettings(
|
fun setProviderSettings(
|
||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -73,6 +74,10 @@ fun ProviderScreen(
|
|||||||
var busy by remember { mutableStateOf<String?>(null) }
|
var busy by remember { mutableStateOf<String?>(null) }
|
||||||
var actionError by remember { mutableStateOf<String?>(null) }
|
var actionError by remember { mutableStateOf<String?>(null) }
|
||||||
var confirmingDelete by remember { mutableStateOf<ProviderModel?>(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
|
// 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,
|
// 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,
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
actionError?.let {
|
actionError?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
@@ -183,6 +216,19 @@ fun ProviderScreen(
|
|||||||
Spacer(Modifier.height(8.dp))
|
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 ->
|
machineModels.actionError?.let { failure ->
|
||||||
item("models-error") {
|
item("models-error") {
|
||||||
Text(failure, color = MaterialTheme.colorScheme.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) {
|
if (confirmingStop) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmingStop = false },
|
onDismissRequest = { confirmingStop = false },
|
||||||
|
|||||||
+57
-2
@@ -94,8 +94,28 @@ pub struct ProviderConfig {
|
|||||||
/// Override for the executable, for an install that isn't on PATH.
|
/// Override for the executable, for an install that isn't on PATH.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub command: Option<String>,
|
pub command: Option<String>,
|
||||||
/// Models offered on the spawn screen. Free text is always allowed
|
/// What updates this provider's program, for an install its own updater
|
||||||
/// too; this is a shortcut list, not a restriction.
|
/// 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")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub models: Vec<String>,
|
pub models: Vec<String>,
|
||||||
/// MCP servers whose tools this provider's sessions can use, on top of
|
/// 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.
|
/// The mode used when a new-session form first selects this kind.
|
||||||
pub fn default_permission_mode(self) -> Option<&'static str> {
|
pub fn default_permission_mode(self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
@@ -799,6 +850,7 @@ impl Config {
|
|||||||
name: ECHO_PROVIDER.to_string(),
|
name: ECHO_PROVIDER.to_string(),
|
||||||
kind: DriverKind::Echo,
|
kind: DriverKind::Echo,
|
||||||
command: None,
|
command: None,
|
||||||
|
update_command: None,
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
model_settings: BTreeMap::new(),
|
model_settings: BTreeMap::new(),
|
||||||
@@ -909,6 +961,7 @@ mod tests {
|
|||||||
name: "claude-cli".to_string(),
|
name: "claude-cli".to_string(),
|
||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: Some("/usr/bin/claude".to_string()),
|
command: Some("/usr/bin/claude".to_string()),
|
||||||
|
update_command: None,
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
model_settings: BTreeMap::new(),
|
model_settings: BTreeMap::new(),
|
||||||
@@ -930,6 +983,7 @@ mod tests {
|
|||||||
name: "claude-cli".to_string(),
|
name: "claude-cli".to_string(),
|
||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: None,
|
command: None,
|
||||||
|
update_command: None,
|
||||||
models: vec!["haiku".to_string()],
|
models: vec!["haiku".to_string()],
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
model_settings: BTreeMap::new(),
|
model_settings: BTreeMap::new(),
|
||||||
@@ -1067,6 +1121,7 @@ sessions: [(
|
|||||||
name: "claude-cli".to_string(),
|
name: "claude-cli".to_string(),
|
||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: Some("/usr/bin/claude".to_string()),
|
command: Some("/usr/bin/claude".to_string()),
|
||||||
|
update_command: None,
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
mcp_servers: Vec::new(),
|
mcp_servers: Vec::new(),
|
||||||
model_settings: BTreeMap::new(),
|
model_settings: BTreeMap::new(),
|
||||||
|
|||||||
+152
-102
@@ -18,7 +18,6 @@
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
use crate::config::{DriverKind, ProviderConfig};
|
use crate::config::{DriverKind, ProviderConfig};
|
||||||
use crate::session::transport::{Launch, Transport};
|
use crate::session::transport::{Launch, Transport};
|
||||||
@@ -36,10 +35,6 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
|
|||||||
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
|
("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
|
/// 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
|
/// PATH: one directory per build, holding either `llama-server` itself or the
|
||||||
/// `bin/llama-server` that `cmake --install` puts there.
|
/// `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
|
// non-interactive ssh session is not the one a person sees when they
|
||||||
// log in, so "it is on my PATH" is not enough.
|
// log in, so "it is on my PATH" is not enough.
|
||||||
command: Some(path.to_string()),
|
command: Some(path.to_string()),
|
||||||
models: match kind {
|
// Set by hand if at all, and so kept by `update_machine` across a
|
||||||
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
|
// re-probe, like the model settings below.
|
||||||
_ => Vec::new(),
|
update_command: None,
|
||||||
},
|
// Asked for when a picker opens instead -- see `provider_models`.
|
||||||
|
models: Vec::new(),
|
||||||
mcp_servers: mcp_defaults(kind),
|
mcp_servers: mcp_defaults(kind),
|
||||||
// What a probe cannot know: how this machine's models are loaded
|
// What a probe cannot know: how this machine's models are loaded
|
||||||
// is configured after the fact, and a re-probe keeps it -- see
|
// is configured after the fact, and a re-probe keeps it -- see
|
||||||
@@ -177,7 +173,7 @@ pub struct OfferedModel {
|
|||||||
|
|
||||||
impl OfferedModel {
|
impl OfferedModel {
|
||||||
/// A model whose id is its own name, which is every provider but llama.
|
/// 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();
|
let id = id.into();
|
||||||
Self {
|
Self {
|
||||||
label: id.clone(),
|
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.
|
/// Models the selected provider currently offers on this machine.
|
||||||
///
|
///
|
||||||
/// Codex's catalog is account- and CLI-version-specific, so it is asked at the
|
/// One question with one answer, asked at the moment a picker opens: every
|
||||||
/// moment the picker opens rather than copied into `config.ron`. A llama.cpp
|
/// catalog here is a property of the machine, the account or the installed
|
||||||
/// provider offers the GGUFs on the machine it runs on, through this same call
|
/// version rather than of this app, so a list copied into `config.ron` is one
|
||||||
/// -- there was a second route answering that alone, and it went when this one
|
/// that goes quietly stale. There was a second route answering llama.cpp's
|
||||||
/// learned to, because a picker offering a model the spawn screen does not, or
|
/// alone, and it went when this one learned to -- a picker offering a model
|
||||||
/// naming it differently, is two answers to one question. Other providers
|
/// the spawn screen does not, or naming it differently, is two answers to one
|
||||||
/// retain the shortcut list discovery stored for them.
|
/// 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(
|
pub async fn provider_models(
|
||||||
transport: &Transport,
|
transport: &Transport,
|
||||||
provider: &ProviderConfig,
|
provider: &ProviderConfig,
|
||||||
models_dir: &std::path::Path,
|
models_dir: &std::path::Path,
|
||||||
) -> Result<Vec<OfferedModel>> {
|
) -> Result<Vec<OfferedModel>> {
|
||||||
if provider.kind == DriverKind::LlamaCpp {
|
match provider.kind {
|
||||||
let dir = crate::models::dir_on(transport, models_dir);
|
DriverKind::ClaudeCli => crate::session::claude::list_models(transport, provider).await,
|
||||||
let mut found = crate::models::on_machine(transport, &dir).await?;
|
DriverKind::CodexCli => crate::session::codex::list_models(transport, provider).await,
|
||||||
// A vision model's projector is a file beside it rather than a model,
|
DriverKind::LlamaCpp => crate::session::llama::list_models(transport, models_dir).await,
|
||||||
// and the session that reads pictures is the one on the model: offered
|
DriverKind::Echo => Ok(provider.models.iter().map(OfferedModel::plain).collect()),
|
||||||
// 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());
|
|
||||||
}
|
}
|
||||||
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>> {
|
/// How long an update is given before it is killed.
|
||||||
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
|
///
|
||||||
anyhow::bail!("Codex could not list models: {message}");
|
/// 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
|
||||||
let entries = answer
|
/// tunnel. It is a backstop rather than a budget: what it is really there for
|
||||||
.pointer("/result/data")
|
/// is a command that stopped to ask something, which with no stdin to read is
|
||||||
.and_then(Value::as_array)
|
/// a wait nothing ends.
|
||||||
.context("Codex returned no model catalog")?;
|
const UPDATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||||
let mut models = entries
|
|
||||||
.iter()
|
/// Which version of its program a provider has, or why that could not be
|
||||||
.filter(|entry| {
|
/// asked.
|
||||||
!entry
|
///
|
||||||
.get("hidden")
|
/// Two states rather than an `Option<String>`, because "this machine could not
|
||||||
.and_then(Value::as_bool)
|
/// be reached" and "this provider has no version" are different things to draw
|
||||||
.unwrap_or(false)
|
/// and an absent string is both. A kind with no program to ask -- echo -- has
|
||||||
})
|
/// neither, and reports `None`.
|
||||||
.filter_map(|entry| entry.get("model").and_then(Value::as_str))
|
#[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)
|
.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.
|
/// 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.len(), 1);
|
||||||
assert_eq!(providers[0].name, "codex-cli");
|
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 {
|
let provider = ProviderConfig {
|
||||||
name: "claude-cli".to_string(),
|
name: "claude-cli".to_string(),
|
||||||
|
update_command: None,
|
||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: Some(cli.display().to_string()),
|
command: Some(cli.display().to_string()),
|
||||||
models: Vec::new(),
|
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}/settings {maxLoaded} -- null for the default
|
||||||
//! POST /machines/{id}/providers/{provider}/model-settings {model, params} -- how it loads
|
//! 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}/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
|
//! POST /machines/{id}/providers/{provider}/unload {model} -- out of memory, server stays
|
||||||
//! GET /machines/{id}/providers/{provider}/models models that provider offers
|
//! GET /machines/{id}/providers/{provider}/models models that provider offers
|
||||||
//! POST /machines/{id}/providers/{provider}/auth begin provider sign-in
|
//! 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",
|
"/machines/{id}/providers/{provider}/settings",
|
||||||
post(set_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(
|
.route(
|
||||||
"/machines/{id}/providers/{provider}/model-settings",
|
"/machines/{id}/providers/{provider}/model-settings",
|
||||||
post(set_model_settings),
|
post(set_model_settings),
|
||||||
@@ -682,9 +690,27 @@ struct ProviderView {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
max_loaded: Option<u32>,
|
max_loaded: Option<u32>,
|
||||||
models: Vec<ProviderModel>,
|
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.
|
/// The tools this provider's sessions have over and above its own.
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
mcp_servers: Vec<String>,
|
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
|
/// Its shared server, or `None` for a provider that has none. The
|
||||||
/// difference matters on screen: "nothing is loaded" and "there is nothing
|
/// difference matters on screen: "nothing is loaded" and "there is nothing
|
||||||
/// here to load" are not the same sentence.
|
/// here to load" are not the same sentence.
|
||||||
@@ -725,9 +751,16 @@ async fn provider_view(
|
|||||||
let machine = machine_by_id(&manager, &id)?;
|
let machine = machine_by_id(&manager, &id)?;
|
||||||
let provider = provider_on(&machine, &provider_name)?.clone();
|
let provider = provider_on(&machine, &provider_name)?.clone();
|
||||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||||
let offered = crate::machines::provider_models(&transport, &provider, manager.models_dir())
|
// Together rather than one after the other: both are a process started on
|
||||||
.await
|
// that machine, and on a remote one each is a round trip down the tunnel.
|
||||||
.map_err(from_machine)?;
|
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);
|
let router = manager.router_for(&machine, &provider);
|
||||||
// Asked of the server only when there is one running: a router that is
|
// 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
|
// down has no opinion about which models are loaded, and inventing
|
||||||
@@ -765,8 +798,11 @@ async fn provider_view(
|
|||||||
kind: provider.kind,
|
kind: provider.kind,
|
||||||
command: provider.command,
|
command: provider.command,
|
||||||
model_params: provider.kind.model_params(),
|
model_params: provider.kind.model_params(),
|
||||||
|
version,
|
||||||
|
updatable: provider.update_command.is_some() || provider.kind.update_args().is_some(),
|
||||||
max_loaded: provider.max_loaded,
|
max_loaded: provider.max_loaded,
|
||||||
models,
|
models,
|
||||||
|
models_error,
|
||||||
mcp_servers: provider
|
mcp_servers: provider
|
||||||
.mcp_servers
|
.mcp_servers
|
||||||
.iter()
|
.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.
|
/// What the provider itself takes, as opposed to what one of its models does.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ use super::process;
|
|||||||
use super::subagent::Subagents;
|
use super::subagent::Subagents;
|
||||||
use super::transport::{Launch, Streams, Transport};
|
use super::transport::{Launch, Streams, Transport};
|
||||||
use crate::config::{ProviderConfig, SessionConfig};
|
use crate::config::{ProviderConfig, SessionConfig};
|
||||||
|
use crate::machines::OfferedModel;
|
||||||
use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
|
use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
|
||||||
|
|
||||||
/// How much of a failing process's stderr the exit report carries. Enough
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1752,4 +1834,35 @@ mod tests {
|
|||||||
assert!(forget_resume_token(dir.path()));
|
assert!(forget_resume_token(dir.path()));
|
||||||
assert_eq!(read_resume_token(dir.path()), None);
|
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::subagent::Subagents;
|
||||||
use super::transport::{Launch, Streams, Transport};
|
use super::transport::{Launch, Streams, Transport};
|
||||||
use crate::config::{ProviderConfig, SessionConfig};
|
use crate::config::{ProviderConfig, SessionConfig};
|
||||||
|
use crate::machines::OfferedModel;
|
||||||
use translate::Translator;
|
use translate::Translator;
|
||||||
|
|
||||||
const STDIN_FIFO: &str = "codex-stdin.fifo";
|
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 == '-')
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2033,4 +2093,26 @@ mod tests {
|
|||||||
assert_eq!(retried["params"]["clientUserMessageId"], "client-message");
|
assert_eq!(retried["params"]["clientUserMessageId"], "client-message");
|
||||||
assert_eq!(retried["params"]["input"][0]["text"], "do not lose me");
|
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>,
|
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.
|
/// 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
|
/// 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) {
|
if let Some(old) = machine.provider(&provider.name) {
|
||||||
provider.model_settings = old.model_settings.clone();
|
provider.model_settings = old.model_settings.clone();
|
||||||
provider.max_loaded = old.max_loaded;
|
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;
|
machine.providers = providers;
|
||||||
@@ -4326,6 +4329,7 @@ mod tests {
|
|||||||
Config::echo_provider(),
|
Config::echo_provider(),
|
||||||
ProviderConfig {
|
ProviderConfig {
|
||||||
name: "stand-in".to_string(),
|
name: "stand-in".to_string(),
|
||||||
|
update_command: None,
|
||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: Some(command.to_string_lossy().into_owned()),
|
command: Some(command.to_string_lossy().into_owned()),
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
|
|||||||
@@ -102,19 +102,35 @@ pub enum Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Transport {
|
impl Transport {
|
||||||
/// Exchanges newline-delimited JSON requests with a short-lived stdio
|
/// Accepts the JSON-RPC answer carrying `id`, for
|
||||||
/// server. `initial` is written first; after its response arrives,
|
/// [`Transport::request_json_blocking`]'s `wanted`.
|
||||||
/// `requests` is written and the response bearing `wanted_id` is returned.
|
|
||||||
///
|
///
|
||||||
/// This is the shape Codex's app-server requires for a usage read: an
|
/// Named rather than written at each call because both of Codex's stdio
|
||||||
/// initialize round trip must finish before the initialized notification
|
/// reads want it and neither of them is where somebody would look for the
|
||||||
/// and account request are accepted.
|
/// 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(
|
pub fn request_json_blocking(
|
||||||
&self,
|
&self,
|
||||||
launch: &Launch,
|
launch: &Launch,
|
||||||
initial: &serde_json::Value,
|
handshake: Option<&serde_json::Value>,
|
||||||
requests: &[serde_json::Value],
|
requests: &[serde_json::Value],
|
||||||
wanted_id: u64,
|
wanted: impl Fn(&serde_json::Value) -> bool,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
|
||||||
@@ -141,22 +157,24 @@ impl Transport {
|
|||||||
.stdout
|
.stdout
|
||||||
.take()
|
.take()
|
||||||
.context("the JSON server has no stdout")?;
|
.context("the JSON server has no stdout")?;
|
||||||
writeln!(stdin, "{initial}")?;
|
|
||||||
stdin.flush()?;
|
|
||||||
let mut reader = BufReader::new(stdout);
|
let mut reader = BufReader::new(stdout);
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
loop {
|
if let Some(handshake) = handshake {
|
||||||
line.clear();
|
writeln!(stdin, "{handshake}")?;
|
||||||
if reader.read_line(&mut line)? == 0 {
|
stdin.flush()?;
|
||||||
anyhow::bail!("the JSON server exited before initialization completed");
|
loop {
|
||||||
}
|
line.clear();
|
||||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
|
if reader.read_line(&mut line)? == 0 {
|
||||||
continue;
|
anyhow::bail!("the JSON server exited before initialization completed");
|
||||||
};
|
}
|
||||||
if value.get("id").and_then(serde_json::Value::as_u64)
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||||
== initial.get("id").and_then(serde_json::Value::as_u64)
|
continue;
|
||||||
{
|
};
|
||||||
break;
|
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 {
|
for request in requests {
|
||||||
@@ -166,12 +184,12 @@ impl Transport {
|
|||||||
loop {
|
loop {
|
||||||
line.clear();
|
line.clear();
|
||||||
if reader.read_line(&mut line)? == 0 {
|
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 {
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if value.get("id").and_then(serde_json::Value::as_u64) == Some(wanted_id) {
|
if wanted(&value) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
return Ok(value);
|
return Ok(value);
|
||||||
|
|||||||
+8
-4
@@ -282,10 +282,12 @@ impl UsageProvider for CodexUsage {
|
|||||||
serde_json::json!({"method": "initialized"}),
|
serde_json::json!({"method": "initialized"}),
|
||||||
serde_json::json!({"id": 2, "method": "account/rateLimits/read"}),
|
serde_json::json!({"id": 2, "method": "account/rateLimits/read"}),
|
||||||
];
|
];
|
||||||
let answer = match self
|
let answer = match self.transport.request_json_blocking(
|
||||||
.transport
|
&launch,
|
||||||
.request_json_blocking(&launch, &initialized, &requests, 2)
|
Some(&initialized),
|
||||||
{
|
&requests,
|
||||||
|
Transport::answer_to(2),
|
||||||
|
) {
|
||||||
Ok(answer) => answer,
|
Ok(answer) => answer,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return vec![self.snapshot(
|
return vec![self.snapshot(
|
||||||
@@ -1105,6 +1107,7 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
providers: vec![crate::config::ProviderConfig {
|
providers: vec![crate::config::ProviderConfig {
|
||||||
name: "claude-cli".to_string(),
|
name: "claude-cli".to_string(),
|
||||||
|
update_command: None,
|
||||||
kind: DriverKind::ClaudeCli,
|
kind: DriverKind::ClaudeCli,
|
||||||
command: None,
|
command: None,
|
||||||
models: vec![],
|
models: vec![],
|
||||||
@@ -1171,6 +1174,7 @@ mod tests {
|
|||||||
let mut echo_only = unreachable_machine();
|
let mut echo_only = unreachable_machine();
|
||||||
echo_only.providers = vec![crate::config::ProviderConfig {
|
echo_only.providers = vec![crate::config::ProviderConfig {
|
||||||
name: "echo".to_string(),
|
name: "echo".to_string(),
|
||||||
|
update_command: None,
|
||||||
kind: DriverKind::Echo,
|
kind: DriverKind::Echo,
|
||||||
command: None,
|
command: None,
|
||||||
models: vec![],
|
models: vec![],
|
||||||
|
|||||||
Reference in new issue
Block a user