merge: setup model wizard
This commit is contained in:
commit
021661b568
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3965,6 +3965,7 @@ dependencies = [
|
||||||
"pod-registry",
|
"pod-registry",
|
||||||
"pod-store",
|
"pod-store",
|
||||||
"protocol",
|
"protocol",
|
||||||
|
"provider",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"secrets",
|
"secrets",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ secrets = { workspace = true }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
pod-store = { workspace = true }
|
pod-store = { workspace = true }
|
||||||
pod-registry = { workspace = true }
|
pod-registry = { workspace = true }
|
||||||
|
provider = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
pulldown-cmark = { version = "0.13.3", default-features = false }
|
pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ mod picker;
|
||||||
mod pod_list;
|
mod pod_list;
|
||||||
mod role_session_registry;
|
mod role_session_registry;
|
||||||
mod scroll;
|
mod scroll;
|
||||||
|
pub mod setup_model;
|
||||||
mod single_pod;
|
mod single_pod;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
mod task;
|
mod task;
|
||||||
|
|
|
||||||
330
crates/tui/src/setup_model.rs
Normal file
330
crates/tui/src/setup_model.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use provider::catalog::{self, AuthHint, ModelEntry, ProviderEntry};
|
||||||
|
use toml::Value;
|
||||||
|
use toml::map::Map;
|
||||||
|
|
||||||
|
const GENERATED_PROFILE_NAME: &str = "default";
|
||||||
|
const GENERATED_PROFILE_PATH: &str = "profiles/default.lua";
|
||||||
|
const GENERATED_PROFILE_DESCRIPTION: &str = "Generated by yoi setup-model";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ModelChoice {
|
||||||
|
pub model_ref: String,
|
||||||
|
pub provider_id: String,
|
||||||
|
pub provider_display_name: String,
|
||||||
|
pub model_id: String,
|
||||||
|
pub auth_hint: AuthHint,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WrittenSetupConfig {
|
||||||
|
pub registry_path: PathBuf,
|
||||||
|
pub profile_path: PathBuf,
|
||||||
|
pub model_ref: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn launch() -> ExitCode {
|
||||||
|
match run_interactive_setup() {
|
||||||
|
Ok(written) => {
|
||||||
|
println!(
|
||||||
|
"Saved default Profile `{}` for model `{}`",
|
||||||
|
GENERATED_PROFILE_NAME, written.model_ref
|
||||||
|
);
|
||||||
|
println!("registry: {}", written.registry_path.display());
|
||||||
|
println!("profile: {}", written.profile_path.display());
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("yoi setup-model: {err}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_interactive_setup() -> Result<WrittenSetupConfig, Box<dyn std::error::Error>> {
|
||||||
|
let config_dir = manifest::paths::config_dir().ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"could not determine yoi config directory",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let choices = load_model_choices()?;
|
||||||
|
let choice = prompt_model_choice(&choices)?;
|
||||||
|
write_default_profile_config(&config_dir, &choice.model_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_model_choices() -> Result<Vec<ModelChoice>, catalog::CatalogError> {
|
||||||
|
let providers = catalog::load_providers()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|provider| (provider.id.clone(), provider))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let mut choices = catalog::load_models()?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|model| choice_from_model(model, &providers))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
choices.sort_by(|a, b| {
|
||||||
|
a.provider_display_name
|
||||||
|
.cmp(&b.provider_display_name)
|
||||||
|
.then_with(|| a.model_id.cmp(&b.model_id))
|
||||||
|
.then_with(|| a.model_ref.cmp(&b.model_ref))
|
||||||
|
});
|
||||||
|
Ok(choices)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choice_from_model(
|
||||||
|
model: ModelEntry,
|
||||||
|
providers: &BTreeMap<String, ProviderEntry>,
|
||||||
|
) -> Option<ModelChoice> {
|
||||||
|
let provider = providers.get(&model.provider)?;
|
||||||
|
Some(ModelChoice {
|
||||||
|
model_ref: format!("{}/{}", model.provider, model.id),
|
||||||
|
provider_id: provider.id.clone(),
|
||||||
|
provider_display_name: provider.display_name.clone(),
|
||||||
|
model_id: model.id,
|
||||||
|
auth_hint: provider.auth_hint.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_model_choice(
|
||||||
|
choices: &[ModelChoice],
|
||||||
|
) -> Result<&ModelChoice, Box<dyn std::error::Error>> {
|
||||||
|
if choices.is_empty() {
|
||||||
|
return Err("no models are configured in the model catalog".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("yoi setup-model");
|
||||||
|
println!();
|
||||||
|
println!("Choose the default model Profile to write under the user config directory.");
|
||||||
|
println!("This command only writes Profile config; it does not start or attach a Pod.");
|
||||||
|
println!();
|
||||||
|
for (idx, choice) in choices.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
"{:>2}. {:<42} {} ({})",
|
||||||
|
idx + 1,
|
||||||
|
choice.model_ref,
|
||||||
|
choice.provider_display_name,
|
||||||
|
auth_hint_label(&choice.auth_hint),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
print!("Select model [1]: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input)?;
|
||||||
|
let selection = input.trim();
|
||||||
|
let index = if selection.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
selection
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| format!("invalid model selection `{selection}`"))?
|
||||||
|
.checked_sub(1)
|
||||||
|
.ok_or("model selection starts at 1")?
|
||||||
|
};
|
||||||
|
choices
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| format!("model selection {} is out of range", index + 1).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_hint_label(hint: &AuthHint) -> String {
|
||||||
|
match hint {
|
||||||
|
AuthHint::None => "no auth".to_string(),
|
||||||
|
AuthHint::ApiKey => "API key file".to_string(),
|
||||||
|
AuthHint::SecretRef { ref_ } => format!("secret `{ref_}`"),
|
||||||
|
AuthHint::CodexOAuth => "Codex OAuth".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_default_profile_config(
|
||||||
|
config_dir: &Path,
|
||||||
|
model_ref: &str,
|
||||||
|
) -> Result<WrittenSetupConfig, Box<dyn std::error::Error>> {
|
||||||
|
std::fs::create_dir_all(config_dir)?;
|
||||||
|
let profiles_dir = config_dir.join("profiles");
|
||||||
|
std::fs::create_dir_all(&profiles_dir)?;
|
||||||
|
|
||||||
|
let profile_path = config_dir.join(GENERATED_PROFILE_PATH);
|
||||||
|
std::fs::write(&profile_path, generated_profile_lua(model_ref))?;
|
||||||
|
|
||||||
|
let registry_path = config_dir.join("profiles.toml");
|
||||||
|
let mut document = read_registry_document(®istry_path)?;
|
||||||
|
set_default_profile_entry(&mut document)?;
|
||||||
|
std::fs::write(®istry_path, toml::to_string_pretty(&document)?)?;
|
||||||
|
|
||||||
|
Ok(WrittenSetupConfig {
|
||||||
|
registry_path,
|
||||||
|
profile_path,
|
||||||
|
model_ref: model_ref.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_registry_document(path: &Path) -> Result<Value, Box<dyn std::error::Error>> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Value::Table(Map::new()));
|
||||||
|
}
|
||||||
|
let text = std::fs::read_to_string(path)?;
|
||||||
|
let value: Value = toml::from_str(&text)?;
|
||||||
|
if !value.is_table() {
|
||||||
|
return Err(format!("{} must contain a TOML table", path.display()).into());
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_profile_entry(document: &mut Value) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let table = document
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or("profiles.toml root must be a TOML table")?;
|
||||||
|
table.insert(
|
||||||
|
"default".to_string(),
|
||||||
|
Value::String(format!("user:{GENERATED_PROFILE_NAME}")),
|
||||||
|
);
|
||||||
|
|
||||||
|
let profile_value = table
|
||||||
|
.entry("profile".to_string())
|
||||||
|
.or_insert_with(|| Value::Table(Map::new()));
|
||||||
|
let profile_table = profile_value
|
||||||
|
.as_table_mut()
|
||||||
|
.ok_or("profiles.toml `profile` must be a TOML table")?;
|
||||||
|
let mut entry = Map::new();
|
||||||
|
entry.insert(
|
||||||
|
"path".to_string(),
|
||||||
|
Value::String(GENERATED_PROFILE_PATH.to_string()),
|
||||||
|
);
|
||||||
|
entry.insert(
|
||||||
|
"description".to_string(),
|
||||||
|
Value::String(GENERATED_PROFILE_DESCRIPTION.to_string()),
|
||||||
|
);
|
||||||
|
profile_table.insert(GENERATED_PROFILE_NAME.to_string(), Value::Table(entry));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_profile_lua(model_ref: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"local profile = require("yoi.profile")
|
||||||
|
local scope = require("yoi.scope")
|
||||||
|
local compact = require("yoi.compact")
|
||||||
|
|
||||||
|
return profile {{
|
||||||
|
slug = "default",
|
||||||
|
description = "Generated by yoi setup-model",
|
||||||
|
|
||||||
|
scope = scope.workspace_write(),
|
||||||
|
|
||||||
|
session = {{
|
||||||
|
record_event_trace = true,
|
||||||
|
}},
|
||||||
|
|
||||||
|
worker = {{
|
||||||
|
reasoning = "high",
|
||||||
|
}},
|
||||||
|
|
||||||
|
model = {{
|
||||||
|
ref = "{}",
|
||||||
|
}},
|
||||||
|
|
||||||
|
compaction = compact.tokens {{
|
||||||
|
threshold = 240000,
|
||||||
|
request_threshold = 270000,
|
||||||
|
worker_context_max_tokens = 100000,
|
||||||
|
}},
|
||||||
|
|
||||||
|
feature = {{
|
||||||
|
task = {{ enabled = true }},
|
||||||
|
memory = {{ enabled = true }},
|
||||||
|
web = {{ enabled = true }},
|
||||||
|
pods = {{ enabled = false }},
|
||||||
|
ticket = {{ enabled = false, access = "lifecycle" }},
|
||||||
|
ticket_orchestration = {{ enabled = false }},
|
||||||
|
}},
|
||||||
|
|
||||||
|
memory = {{
|
||||||
|
extract_threshold = 50000,
|
||||||
|
consolidation_threshold_files = 5,
|
||||||
|
consolidation_threshold_bytes = 50000,
|
||||||
|
}},
|
||||||
|
|
||||||
|
web = {{
|
||||||
|
enabled = true,
|
||||||
|
search = {{
|
||||||
|
provider = "brave",
|
||||||
|
api_key_secret = "web/brave/default",
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
escape_lua_string(model_ref)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_lua_string(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.flat_map(|c| match c {
|
||||||
|
'\\' => "\\\\".chars().collect::<Vec<_>>(),
|
||||||
|
'"' => "\\\"".chars().collect::<Vec<_>>(),
|
||||||
|
'\n' => "\\n".chars().collect::<Vec<_>>(),
|
||||||
|
'\r' => "\\r".chars().collect::<Vec<_>>(),
|
||||||
|
'\t' => "\\t".chars().collect::<Vec<_>>(),
|
||||||
|
other => vec![other],
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_default_profile_config_creates_registry_and_profile() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let written = write_default_profile_config(dir.path(), "codex-oauth/gpt-5.5").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(written.registry_path, dir.path().join("profiles.toml"));
|
||||||
|
assert_eq!(
|
||||||
|
written.profile_path,
|
||||||
|
dir.path().join("profiles/default.lua")
|
||||||
|
);
|
||||||
|
|
||||||
|
let registry = std::fs::read_to_string(&written.registry_path).unwrap();
|
||||||
|
assert!(registry.contains("default = \"user:default\""));
|
||||||
|
assert!(registry.contains("[profile.default]"));
|
||||||
|
assert!(registry.contains("path = \"profiles/default.lua\""));
|
||||||
|
|
||||||
|
let profile = std::fs::read_to_string(&written.profile_path).unwrap();
|
||||||
|
assert!(profile.contains("slug = \"default\""));
|
||||||
|
assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\""));
|
||||||
|
assert!(profile.contains("scope = scope.workspace_write()"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_default_profile_config_preserves_other_profile_entries() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("profiles.toml"),
|
||||||
|
r#"[profile.other]
|
||||||
|
path = "profiles/other.lua"
|
||||||
|
description = "keep me"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
write_default_profile_config(dir.path(), "anthropic/claude-sonnet-4-6").unwrap();
|
||||||
|
|
||||||
|
let registry = std::fs::read_to_string(dir.path().join("profiles.toml")).unwrap();
|
||||||
|
assert!(registry.contains("[profile.other]"));
|
||||||
|
assert!(registry.contains("path = \"profiles/other.lua\""));
|
||||||
|
assert!(registry.contains("[profile.default]"));
|
||||||
|
assert!(registry.contains("default = \"user:default\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_lua_string_escapes_quotes_and_slashes() {
|
||||||
|
assert_eq!(escape_lua_string("a\\b\"c"), "a\\\\b\\\"c");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ enum Mode {
|
||||||
Ticket(ticket_cli::TicketCli),
|
Ticket(ticket_cli::TicketCli),
|
||||||
PodRuntime(Vec<String>),
|
PodRuntime(Vec<String>),
|
||||||
Keys,
|
Keys,
|
||||||
|
SetupModel,
|
||||||
Tui {
|
Tui {
|
||||||
mode: LaunchMode,
|
mode: LaunchMode,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
|
|
@ -107,6 +108,7 @@ async fn main() -> ExitCode {
|
||||||
},
|
},
|
||||||
Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("yoi pod", args).await,
|
Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("yoi pod", args).await,
|
||||||
Mode::Keys => tui::keys::launch().await,
|
Mode::Keys => tui::keys::launch().await,
|
||||||
|
Mode::SetupModel => tui::setup_model::launch().await,
|
||||||
Mode::Tui {
|
Mode::Tui {
|
||||||
mode,
|
mode,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
|
|
@ -183,6 +185,14 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
}
|
}
|
||||||
return Ok(Mode::Keys);
|
return Ok(Mode::Keys);
|
||||||
}
|
}
|
||||||
|
"setup-model" => {
|
||||||
|
if args.len() != 1 {
|
||||||
|
return Err(ParseError(
|
||||||
|
"yoi setup-model does not accept arguments".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(Mode::SetupModel);
|
||||||
|
}
|
||||||
"memory" if args.get(1).map(String::as_str) == Some("lint") => {
|
"memory" if args.get(1).map(String::as_str) == Some("lint") => {
|
||||||
let lint_args = &args[2..];
|
let lint_args = &args[2..];
|
||||||
if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
|
|
@ -433,7 +443,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -544,6 +554,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_setup_model_subcommand() {
|
||||||
|
match parse_args_from(["setup-model"]).unwrap() {
|
||||||
|
Mode::SetupModel => {}
|
||||||
|
_ => panic!("expected SetupModel mode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_setup_model_rejects_arguments() {
|
||||||
|
let err = parse_args_from(["setup-model", "extra"]).unwrap_err();
|
||||||
|
assert_eq!(err.to_string(), "yoi setup-model does not accept arguments");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_literal_pod_name_still_available_with_flag() {
|
fn parse_literal_pod_name_still_available_with_flag() {
|
||||||
match parse_args_from(["--pod", "pod"]).unwrap() {
|
match parse_args_from(["--pod", "pod"]).unwrap() {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-tMTB7BEwrWvZYS3LobuG+l2stfRqgVYZKnBqFPX62Do=";
|
cargoHash = "sha256-WvMpHbTswYeRrkw5I4V4E1RnG7j13PbuQCbeas/XILs=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user