Make model and permission choices provider-specific
This commit is contained in:
1 parent
6a0202b1b5
commit
7ee88dfd9c
10 files changed
+254
-45
No files matched your search
@@ -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 },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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>>,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user