Make model and permission choices provider-specific
This commit is contained in:
1 parent
6a0202b1b5
commit
7ee88dfd9c
10 files changed
+269
-60
No files matched your search
@@ -258,6 +258,26 @@ impl DriverKind {
|
||||
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)]
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! POST /setups/probe dry run {ssh?}: what would be found there
|
||||
//! 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}/providers/{provider}/models models a CLI currently offers
|
||||
//! 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
|
||||
//! 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.
|
||||
.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
|
||||
// rather than under a session because a filesystem is a property of
|
||||
// a machine; a session only says where to start looking.
|
||||
@@ -290,6 +295,9 @@ struct ProviderInfo {
|
||||
name: String,
|
||||
kind: crate::config::DriverKind,
|
||||
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>> {
|
||||
@@ -308,6 +316,8 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
models: provider.models,
|
||||
permission_modes: provider.kind.permission_modes().to_vec(),
|
||||
default_permission_mode: provider.kind.default_permission_mode(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
@@ -412,6 +422,8 @@ async fn probe_setup(
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
models: provider.models,
|
||||
permission_modes: provider.kind.permission_modes().to_vec(),
|
||||
default_permission_mode: provider.kind.default_permission_mode(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
@@ -570,6 +582,24 @@ async fn setup_models(
|
||||
.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.
|
||||
async fn list_dir(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
|
||||
@@ -303,14 +303,16 @@ fn start_process(
|
||||
let settings = inner.settings.lock().unwrap();
|
||||
let mut args = Vec::new();
|
||||
match settings.permission_mode.as_deref() {
|
||||
Some("bypassPermissions") => {
|
||||
Some("bypassPermissions" | "danger-full-access") => {
|
||||
args.push("--dangerously-bypass-approvals-and-sandbox".to_string());
|
||||
}
|
||||
Some("manual") => {
|
||||
args.extend(["--ask-for-approval".to_string(), "on-request".to_string()]);
|
||||
}
|
||||
Some("auto" | "acceptEdits") => args.push("--approve-for-me".to_string()),
|
||||
Some("plan") => args.extend([
|
||||
Some("auto" | "acceptEdits" | "workspace-write") => {
|
||||
args.push("--approve-for-me".to_string());
|
||||
}
|
||||
Some("plan" | "read-only") => args.extend([
|
||||
"--ask-for-approval".to_string(),
|
||||
"never".to_string(),
|
||||
"--sandbox".to_string(),
|
||||
|
||||
@@ -91,6 +91,7 @@ pub enum Streams {
|
||||
}
|
||||
|
||||
/// The machine a session's process runs on.
|
||||
#[derive(Clone)]
|
||||
pub enum Transport {
|
||||
/// The machine this server is running on.
|
||||
Here,
|
||||
|
||||
+78
-1
@@ -14,7 +14,8 @@
|
||||
//! is editing `config.ron` on the backend, which is exactly the authority the
|
||||
//! phone is not being given.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::{DriverKind, ProviderConfig};
|
||||
use crate::session::transport::{Launch, Transport};
|
||||
@@ -87,6 +88,60 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
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.
|
||||
///
|
||||
/// 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("/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