Make model and permission choices provider-specific

This commit is contained in:
iris committed 2026-09-08 00:08:09 -04:00
1 parent 6a0202b1b5
commit 7ee88dfd9c
10 files changed
+254 -45

No files matched your search

+7
View File
@@ -221,6 +221,13 @@ phone and auto-resume need no Codex branch. This is the CLI's local protocol
and is treated defensively for the same reason as Claude's undocumented usage and is treated defensively for the same reason as Claude's undocumented usage
endpoint: missing fields or a refusal degrade to an unavailable snapshot. endpoint: missing fields or a refusal degrade to an unavailable snapshot.
The model picker asks the selected setup's Codex app-server for `model/list`
when it is opened. The catalog is account- and CLI-version-specific, so it is
never copied into the app or inferred from another setup; a lookup failure is
shown as unavailable while the free-text escape remains. Permission choices
are likewise reported per provider: Codex offers its read-only,
workspace-write and full-access modes, while Claude keeps its own modes.
### The llama driver ### The llama driver
One `llama-server` per session, started through the same `Transport` as any One `llama-server` per session, started through the same `Transport` as any
@@ -330,7 +330,13 @@ fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: Li
// //
// One list rather than two. A provider only exists on a machine that has it installed, so offering // One list rather than two. A provider only exists on a machine that has it installed, so offering
// machines and providers as independent choices would offer pairs that cannot work. // machines and providers as independent choices would offer pairs that cannot work.
data class Provider(val name: String, val kind: String, val models: List<String>) data class Provider(
val name: String,
val kind: String,
val models: List<String>,
val permissionModes: List<String>,
val defaultPermissionMode: String?,
)
/** /**
* A machine, and what it can run. [address] is absent for the backend itself. * A machine, and what it can run. [address] is absent for the backend itself.
@@ -345,13 +351,17 @@ data class Setup(
val providers: List<Provider>, val providers: List<Provider>,
) )
private fun parseProvider(provider: JSONObject) = private fun parseProvider(provider: JSONObject): Provider {
Provider( val kind = provider.getString("kind")
return Provider(
name = provider.getString("name"), name = provider.getString("name"),
kind = provider.getString("kind"), kind = kind,
// Omitted entirely when the provider offers none. // Omitted entirely when the provider offers none.
models = provider.optJSONArray("models")?.strings().orEmpty(), models = provider.optJSONArray("models")?.strings().orEmpty(),
permissionModes = provider.optJSONArray("permissionModes")?.strings().orEmpty(),
defaultPermissionMode = provider.optString("defaultPermissionMode").ifEmpty { null },
) )
}
private fun parseSetup(setup: JSONObject) = private fun parseSetup(setup: JSONObject) =
Setup( Setup(
@@ -1083,16 +1093,6 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
) {} ) {}
} }
/**
* Common permission intents, in the order they give up asking. Each coding CLI maps these onto its
* own flags: "manual" asks, "acceptEdits" and "auto" allow ordinary work, "bypassPermissions"
* accepts everything, and "plan" makes no changes.
*
* One list for every screen that offers them -- spawn, import, and the session's own picker --
* because three copies had already drifted: the import screen was missing "plan".
*/
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
/** /**
* What a new session's thinking level is when nothing chose one, or null for the CLI's own. * What a new session's thinking level is when nothing chose one, or null for the CLI's own.
* *
@@ -1294,6 +1294,19 @@ fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel
} }
} }
/** The current model catalog for one CLI provider on the machine where it runs. */
fun fetchProviderModels(
settings: ServerSettings,
setupId: String,
provider: String,
): List<String> =
requestFromServer(
settings,
"/setups/${setupId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
) { connection ->
JSONArray(connection.inputStream.bufferedReader().readText()).strings()
}
fun fetchModels(settings: ServerSettings): Models = fun fetchModels(settings: ServerSettings): Models =
requestFromServer(settings, "/models") { connection -> requestFromServer(settings, "/models") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText()) val body = JSONObject(connection.inputStream.bufferedReader().readText())
@@ -100,9 +100,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
// themselves, not a flag, so the dialog can say what it is about. // themselves, not a flag, so the dialog can say what it is about.
var confirming by remember { mutableStateOf<List<Importable>?>(null) } var confirming by remember { mutableStateOf<List<Importable>?>(null) }
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty // Set from the selected Claude provider rather than repeated in the app.
// times. var permissionMode by remember { mutableStateOf("") }
var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it, // When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// so a tap reading it needs no recomposition. // so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() } val movedAt = remember { mutableMapOf<String, Long>() }
@@ -208,6 +207,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} }
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
LaunchedEffect(chosen?.id, provider?.name) {
permissionMode = provider?.defaultPermissionMode.orEmpty()
}
/** Continues [targets] in the background, leaving the screen where it is. */ /** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) { fun importAll(targets: List<Importable>) {
@@ -375,7 +377,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
} else { } else {
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = PERMISSION_MODES, options = provider?.permissionModes.orEmpty(),
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
@@ -305,6 +305,7 @@ fun SessionScreen(
// The models this provider actually offers, asked of the server rather than listed here: a // The models this provider actually offers, asked of the server rather than listed here: a
// hardcoded list is a claim about a machine. // hardcoded list is a claim about a machine.
var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) } var offeredModels by remember { mutableStateOf<List<String>>(emptyList()) }
var offeredPermissionModes by remember { mutableStateOf<List<String>>(emptyList()) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
// The resume cursor, written from the stream's IO thread. // The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) } val lastSeq = remember { AtomicLong(0) }
@@ -1017,23 +1018,29 @@ fun SessionScreen(
// Only for the model picker, which a subagent does not have. // Only for the model picker, which a subagent does not have.
if (!isSubagent) { if (!isSubagent) {
LaunchedEffect(summary.setupName, summary.provider) { LaunchedEffect(summary.setup, summary.provider) {
offeredModels = val provider = runCatching {
try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
fetchSetups(settings) fetchSetups(settings)
.firstOrNull { it.name == summary.setupName } .firstOrNull { it.id == summary.setup }
?.providers ?.providers
?.firstOrNull { it.name == summary.provider } ?.firstOrNull { it.name == summary.provider }
?.models }
}
.getOrNull()
offeredPermissionModes = provider?.permissionModes.orEmpty()
offeredModels =
provider
?.let {
runCatching {
withContext(Dispatchers.IO) {
fetchProviderModels(settings, summary.setup, summary.provider)
}
}
.getOrDefault(emptyList())
}
.orEmpty() .orEmpty()
} }
} catch (_: Exception) {
// Not worth reporting: the picker simply has nothing to offer, which is
// visible.
emptyList()
}
}
} }
/** /**
@@ -1840,14 +1847,22 @@ fun SessionScreen(
}, },
) )
} }
if (offeredPermissionModes.isNotEmpty()) {
PickerButton( PickerButton(
current = permissionMode, current = permissionMode,
options = PERMISSION_MODES, options = offeredPermissionModes,
onPick = { chosen -> onPick = { chosen ->
act { setSessionPermissionMode(settings, summary.id, chosen) } act {
setSessionPermissionMode(
settings,
summary.id,
chosen,
)
}
}, },
) )
} }
}
// The same filled shape as the button beside it, not an outlined one: these // The same filled shape as the button beside it, not an outlined one: these
// are two things you can do about the session, and weighting one as // are two things you can do about the session, and weighting one as
// secondary said they were a primary action and its qualifier. What // secondary said they were a primary action and its qualifier. What
@@ -57,10 +57,12 @@ fun SpawnScreen(
var providerName by remember { mutableStateOf<String?>(null) } var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") } var model by remember { mutableStateOf("") }
var providerModels by remember { mutableStateOf<List<String>>(emptyList()) }
var providerModelsLoading by remember { mutableStateOf(false) }
var providerModelsError by remember { mutableStateOf<String?>(null) }
var cwd by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") }
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and // Set only after the selected provider reports its own default. An empty value is not sent.
// answering "allow Bash?" dozens of times per task is what this app exists to avoid. var permissionMode by remember { mutableStateOf("") }
var permissionMode by remember { mutableStateOf("auto") }
// Null until the server has been asked, and null again if it answers "no level chosen" -- the // Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer // two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it. // arrives is one you can spawn at without having chosen it.
@@ -70,10 +72,8 @@ fun SpawnScreen(
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in. // form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) } var spawnError by remember { mutableStateOf<String?>(null) }
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate // GGUFs on the chosen machine, for a llama provider to choose between. Kept separate from a
// from the setups: a Claude session needs none, so failing to list them must not stop the // coding CLI's provider catalog and refetched when the machine changes.
// screen rendering. Refetched when the machine changes, because a model is a file on one
// machine -- see [fetchSetupModels].
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) } var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) } var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") } var contextSize by remember { mutableStateOf("") }
@@ -145,6 +145,28 @@ fun SpawnScreen(
val isCodingCli = isClaude || isCodex val isCodingCli = isClaude || isCodex
val isLlama = current?.kind == "llama_cpp" val isLlama = current?.kind == "llama_cpp"
LaunchedEffect(setup?.id, current?.name) {
model = ""
providerModels = emptyList()
providerModelsError = null
permissionMode = current?.defaultPermissionMode.orEmpty()
if (isCodingCli) {
providerModelsLoading = true
try {
providerModels =
withContext(Dispatchers.IO) {
fetchProviderModels(settings, setup.id, current.name)
}
} catch (e: ApiException) {
providerModelsError = e.message
} finally {
providerModelsLoading = false
}
} else {
providerModelsLoading = false
}
}
// The machine first, because it decides what can be run at all. // The machine first, because it decides what can be run at all.
ChipGroup( ChipGroup(
label = "Setup", label = "Setup",
@@ -240,15 +262,35 @@ fun SpawnScreen(
} }
if (isCodingCli) { if (isCodingCli) {
if (current.models.isNotEmpty()) { when {
providerModelsLoading ->
Text(
"Loading model choices…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
providerModelsError != null ->
Text(
"Model choices unavailable: $providerModelsError",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
providerModels.isEmpty() ->
Text(
"This setup reported no selectable models.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
ChipGroup( ChipGroup(
label = "Model", label = "Model",
options = current.models, options = providerModels,
selected = model.ifEmpty { null }, selected = model.ifEmpty { null },
onSelect = { chosen -> model = if (model == chosen) "" else chosen }, onSelect = { chosen -> model = if (model == chosen) "" else chosen },
) )
} }
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = model, value = model,
@@ -271,7 +313,7 @@ fun SpawnScreen(
ChipGroup( ChipGroup(
label = "Permissions", label = "Permissions",
options = PERMISSION_MODES, options = current.permissionModes,
selected = permissionMode, selected = permissionMode,
onSelect = { permissionMode = it }, onSelect = { permissionMode = it },
) )
+20
View File
@@ -258,6 +258,26 @@ impl DriverKind {
Self::Echo | Self::LlamaCpp => false, Self::Echo | Self::LlamaCpp => false,
} }
} }
/// Permission choices the phone can offer for this kind, in display order.
/// The driver remains responsible for translating these stable values into
/// its CLI's arguments or control protocol.
pub fn permission_modes(self) -> &'static [&'static str] {
match self {
Self::ClaudeCli => &["manual", "acceptEdits", "auto", "bypassPermissions", "plan"],
Self::CodexCli => &["workspace-write", "read-only", "danger-full-access"],
Self::Echo | Self::LlamaCpp => &[],
}
}
/// The mode used when a new-session form first selects this kind.
pub fn default_permission_mode(self) -> Option<&'static str> {
match self {
Self::ClaudeCli => Some("auto"),
Self::CodexCli => Some("workspace-write"),
Self::Echo | Self::LlamaCpp => None,
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
+30
View File
@@ -8,6 +8,7 @@
//! POST /setups/probe dry run {ssh?}: what would be found there //! POST /setups/probe dry run {ssh?}: what would be found there
//! GET /setups/{id} one machine, for refetching after a change //! GET /setups/{id} one machine, for refetching after a change
//! GET /setups/{id}/models GGUFs on that machine, for a llama session //! GET /setups/{id}/models GGUFs on that machine, for a llama session
//! GET /setups/{id}/providers/{provider}/models models a CLI currently offers
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved //! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
//! GET /setups/{id}/file?path=P content of file P, or why not //! GET /setups/{id}/file?path=P content of file P, or why not
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256 //! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
@@ -127,6 +128,10 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
) )
// The models on the machine a setup names, for a llama session there. // The models on the machine a setup names, for a llama session there.
.route("/setups/{id}/models", get(setup_models)) .route("/setups/{id}/models", get(setup_models))
.route(
"/setups/{id}/providers/{provider}/models",
get(provider_models),
)
// The filesystem of the machine a setup names. Under the setup // The filesystem of the machine a setup names. Under the setup
// rather than under a session because a filesystem is a property of // rather than under a session because a filesystem is a property of
// a machine; a session only says where to start looking. // a machine; a session only says where to start looking.
@@ -290,6 +295,9 @@ struct ProviderInfo {
name: String, name: String,
kind: crate::config::DriverKind, kind: crate::config::DriverKind,
models: Vec<String>, models: Vec<String>,
permission_modes: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
default_permission_mode: Option<&'static str>,
} }
async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> { async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> {
@@ -308,6 +316,8 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
name: provider.name, name: provider.name,
kind: provider.kind, kind: provider.kind,
models: provider.models, models: provider.models,
permission_modes: provider.kind.permission_modes().to_vec(),
default_permission_mode: provider.kind.default_permission_mode(),
}) })
.collect(), .collect(),
} }
@@ -412,6 +422,8 @@ async fn probe_setup(
name: provider.name, name: provider.name,
kind: provider.kind, kind: provider.kind,
models: provider.models, models: provider.models,
permission_modes: provider.kind.permission_modes().to_vec(),
default_permission_mode: provider.kind.default_permission_mode(),
}) })
.collect(), .collect(),
)) ))
@@ -570,6 +582,24 @@ async fn setup_models(
.map_err(from_machine) .map_err(from_machine)
} }
/// The models a CLI provider currently offers on the setup's machine.
/// Codex answers from its live account catalog; providers with a configured
/// shortcut list return that list.
async fn provider_models(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, provider_name)): UrlPath<(String, String)>,
) -> Result<axum::Json<Vec<String>>, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let provider = setup.provider(&provider_name).ok_or_else(|| {
ApiError::NotFound(format!("no provider {provider_name} on {}", setup.name))
})?;
let transport = crate::session::transport::Transport::for_setup(&setup);
crate::setups::provider_models(&transport, provider)
.await
.map(axum::Json)
.map_err(from_machine)
}
/// What is in a directory, and what that directory resolved to. /// What is in a directory, and what that directory resolved to.
async fn list_dir( async fn list_dir(
State(manager): State<Arc<SessionManager>>, State(manager): State<Arc<SessionManager>>,
+5 -3
View File
@@ -303,14 +303,16 @@ fn start_process(
let settings = inner.settings.lock().unwrap(); let settings = inner.settings.lock().unwrap();
let mut args = Vec::new(); let mut args = Vec::new();
match settings.permission_mode.as_deref() { match settings.permission_mode.as_deref() {
Some("bypassPermissions") => { Some("bypassPermissions" | "danger-full-access") => {
args.push("--dangerously-bypass-approvals-and-sandbox".to_string()); args.push("--dangerously-bypass-approvals-and-sandbox".to_string());
} }
Some("manual") => { Some("manual") => {
args.extend(["--ask-for-approval".to_string(), "on-request".to_string()]); args.extend(["--ask-for-approval".to_string(), "on-request".to_string()]);
} }
Some("auto" | "acceptEdits") => args.push("--approve-for-me".to_string()), Some("auto" | "acceptEdits" | "workspace-write") => {
Some("plan") => args.extend([ args.push("--approve-for-me".to_string());
}
Some("plan" | "read-only") => args.extend([
"--ask-for-approval".to_string(), "--ask-for-approval".to_string(),
"never".to_string(), "never".to_string(),
"--sandbox".to_string(), "--sandbox".to_string(),
+1
View File
@@ -91,6 +91,7 @@ pub enum Streams {
} }
/// The machine a session's process runs on. /// The machine a session's process runs on.
#[derive(Clone)]
pub enum Transport { pub enum Transport {
/// The machine this server is running on. /// The machine this server is running on.
Here, Here,
+78 -1
View File
@@ -14,7 +14,8 @@
//! is editing `config.ron` on the backend, which is exactly the authority the //! is editing `config.ron` on the backend, which is exactly the authority the
//! phone is not being given. //! phone is not being given.
use anyhow::Result; use anyhow::{Context, Result};
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};
@@ -87,6 +88,60 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
Ok(providers) Ok(providers)
} }
/// 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`. Other
/// providers retain the shortcut list discovery stored for them.
pub async fn provider_models(
transport: &Transport,
provider: &ProviderConfig,
) -> Result<Vec<String>> {
if provider.kind != DriverKind::CodexCli {
return Ok(provider.models.clone());
}
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)?;
parse_codex_models(&answer)
})
.await?
}
fn parse_codex_models(answer: &Value) -> Result<Vec<String>> {
if let Some(message) = answer.pointer("/error/message").and_then(Value::as_str) {
anyhow::bail!("Codex could not list models: {message}");
}
let entries = answer
.pointer("/result/data")
.and_then(Value::as_array)
.context("Codex returned no model catalog")?;
let mut models = entries
.iter()
.filter(|entry| {
!entry
.get("hidden")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.filter_map(|entry| entry.get("model").and_then(Value::as_str))
.map(str::to_string)
.collect::<Vec<_>>();
models.dedup();
Ok(models)
}
/// Adds what to do to failures whose own wording does not say. /// Adds what to do to failures whose own wording does not say.
/// ///
/// ssh's messages are written for someone at a terminal on the backend, which is /// ssh's messages are written for someone at a terminal on the backend, which is
@@ -213,4 +268,26 @@ mod tests {
assert_eq!(shorten_home(&sibling), sibling); assert_eq!(shorten_home(&sibling), sibling);
assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts"); assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts");
} }
#[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"
);
}
} }