@setoelkahfi / sigit / commits / 29b45d7

Make account management slash commands; cloud tiers in /models

- /login, /logout, /whoami as slash commands in the TUI and ACP, backed by I/O-free core functions in account.rs (drops the argv subcommands and rpassword) - login is auth only: it no longer auto-selects the cloud backend - /models always lists the siGit Code Cloud tiers (Fast/Balanced/Large) next to on-device models; sign-in is gated at selection, not visibility - selecting a model or tier hot-swaps the running backend in place - account API client matches sigit.si /api/v1 (auth/sign_in, me, auth/sign_out) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 22, 2026 at 22:49 UTC 29b45d707047c5bae2b5335f1419248ada0e21af
7 files changed +307 -180
Cargo.lock
-22
index 781357f..3df9923 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4586,27 +4586,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rpassword" -version = "7.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.61.2", -] - -[[package]] -name = "rtoolbox" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "rubato" version = "0.16.2" @@ -5223,7 +5202,6 @@ dependencies = [ "ratatui", "regex", "reqwest 0.12.28", - "rpassword", "serde", "serde_json", "tokio",
Cargo.toml
-1
index d5788e5..af87312 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" async-trait = "0.1" -rpassword = "7" regex = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } uuid = { version = "1", features = ["v4"] }
src/account.rs
+83 -86
index 1dee99c..a52ae17 100644 --- a/src/account.rs +++ b/src/account.rs @@ -1,7 +1,10 @@ -//! Account commands: `login`, `logout`, `whoami`. +//! Account access for siGit Code Cloud, surfaced as the `/login`, `/logout`, +//! and `/whoami` slash commands in both the TUI and ACP sessions. //! -//! These authenticate against the siGit account API and store a session token -//! locally. The token is the credential used for siGit Code Cloud requests. +//! These functions authenticate against the siGit account API and store a +//! session token locally. The token is the credential used for siGit Code Cloud +//! requests. They perform no console I/O, so each slash surface can render the +//! returned message however it likes. //! //! Base URL: `$SIGIT_API_URL`, else `https://sigit.si`. @@ -16,25 +19,12 @@ fn api_base() -> String { std::env::var("SIGIT_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string()) } -// ── sigit.si /api/v1 response shapes ───────────────────────────────────────────── - -/// Sign-in response. A successful sign-in carries an `access_token`; an -/// unverified account reports a `status`; failures arrive as an `error`. -#[derive(Debug, Deserialize)] -struct SignInResponse { - #[serde(default)] - access_token: Option<String>, - #[serde(default)] - status: Option<String>, - #[serde(default)] - error: Option<ApiError>, -} - -#[derive(Debug, Deserialize)] -struct ApiError { - #[serde(default)] - message: Option<String>, -} +// Sign-in returns an `AccountStatus`, one of: +// "NotFound" (a bare JSON string) +// {"Ready":{"access_token":"…"}} +// {"Incomplete":{"status":<u32>}} +// Failures return {"error_code":<i32>,"message":"…"}. Parsed from a +// `serde_json::Value` rather than a struct because of the bare-string variant. #[derive(Debug, Deserialize)] struct MeResponse { @@ -42,60 +32,70 @@ struct MeResponse { email: Option<String>, } -// ── Commands ────────────────────────────────────────────────────────────────────── - -/// `sigit login`: prompt for credentials, authenticate, and store the token. -pub async fn login() -> anyhow::Result<()> { - let base = api_base(); - println!("Sign in to siGit Code Cloud ({base})"); - - let email = prompt("Email: ")?; - let password = rpassword::prompt_password("Password: ")?; - if email.trim().is_empty() || password.is_empty() { - anyhow::bail!("email and password are required"); +/// Authenticate with email and password, storing the session token on success. +/// Returns the signed-in email, or a human-readable error message. +pub async fn authenticate(email: &str, password: &str) -> Result<String, String> { + let email = email.trim(); + if email.is_empty() || password.is_empty() { + return Err("email and password are required".to_string()); } - let url = format!("{}/api/v1/users/sign_in", base.trim_end_matches('/')); + let url = format!("{}/api/v1/auth/sign_in", api_base().trim_end_matches('/')); let response = reqwest::Client::new() .post(&url) - .json(&serde_json::json!({ "email": email.trim(), "password": password })) + .json(&serde_json::json!({ "email": email, "password": password })) .send() .await - .map_err(|error| anyhow::anyhow!("could not reach siGit Code Cloud: {error}"))?; + .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?; let status = response.status(); - let parsed: SignInResponse = response + let body: serde_json::Value = response .json() .await - .map_err(|error| anyhow::anyhow!("unexpected response from siGit Code Cloud: {error}"))?; - - if let Some(token) = parsed.access_token.filter(|token| !token.trim().is_empty()) { - credentials::store(&Credentials { - access_token: token, - email: Some(email.trim().to_string()), - }) - .map_err(|error| anyhow::anyhow!("could not save session: {error}"))?; - println!("✓ Signed in as {}. siGit Code Cloud is ready.", email.trim()); - return Ok(()); + .map_err(|error| format!("unexpected response from siGit Code Cloud: {error}"))?; + + if status.is_success() { + // AccountStatus::Ready + if let Some(token) = body + .get("Ready") + .and_then(|ready| ready.get("access_token")) + .and_then(|token| token.as_str()) + .filter(|token| !token.trim().is_empty()) + { + credentials::store(&Credentials { + access_token: token.to_string(), + email: Some(email.to_string()), + })?; + return Ok(email.to_string()); + } + // AccountStatus::Incomplete + if body.get("Incomplete").is_some() { + return Err( + "your account is not verified yet. Check your email to confirm it, then sign in again." + .to_string(), + ); + } + // AccountStatus::NotFound (a bare JSON string) + if body.as_str() == Some("NotFound") { + return Err(format!("no siGit account found for {email}.")); + } + return Err("unexpected sign-in response from siGit Code Cloud".to_string()); } - // No token: surface the most specific message available. - if let Some(message) = parsed.error.and_then(|error| error.message) { - anyhow::bail!("sign-in failed: {message}"); - } - if let Some(account_status) = parsed.status { - anyhow::bail!( - "sign-in incomplete (status: {account_status}). Check your email to verify your account." - ); - } - anyhow::bail!("sign-in failed (HTTP {})", status.as_u16()); + // ErrorResponse { error_code, message } + let message = body + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("sign-in failed"); + Err(format!("sign-in failed: {message}")) } -/// `sigit logout`: clear the local session, notifying the server best-effort. -pub async fn logout() -> anyhow::Result<()> { +/// Clear the local session, notifying the server best-effort. Returns a message +/// suitable for display. +pub async fn end_session() -> String { if let Some(token) = credentials::load_token() { - let url = format!("{}/api/v1/users/sign_out", api_base().trim_end_matches('/')); - // Best-effort: a failed server call must not block local logout. + let url = format!("{}/api/v1/auth/sign_out", api_base().trim_end_matches('/')); + // A failed server call must not block local sign-out. let _ = reqwest::Client::new() .delete(&url) .bearer_auth(&token) @@ -103,18 +103,16 @@ pub async fn logout() -> anyhow::Result<()> { .await; } if credentials::clear() { - println!("✓ Signed out of siGit Code Cloud."); + "Signed out of siGit Code Cloud.".to_string() } else { - println!("Not signed in."); + "Not signed in.".to_string() } - Ok(()) } -/// `sigit whoami`: show the signed-in account, verifying the token if reachable. -pub async fn whoami() -> anyhow::Result<()> { +/// One-line description of the current session, verifying the token if reachable. +pub async fn status_line() -> String { let Some(creds) = credentials::load() else { - println!("Not signed in. Run `sigit login` to use siGit Code Cloud."); - return Ok(()); + return "Not signed in. Use `/login <email> <password>` to use siGit Code Cloud.".to_string(); }; let url = format!("{}/api/v1/me", api_base().trim_end_matches('/')); @@ -132,29 +130,28 @@ pub async fn whoami() -> anyhow::Result<()> { .and_then(|me| me.email) .or(creds.email) .unwrap_or_else(|| "(unknown)".to_string()); - println!("Signed in to siGit Code Cloud as {email}."); - } - Ok(response) => { - println!( - "Session may be expired (HTTP {}). Run `sigit login` again.", - response.status().as_u16() - ); + format!("Signed in to siGit Code Cloud as {email}.") } + Ok(response) => format!( + "Session may be expired (HTTP {}). Use `/login` again.", + response.status().as_u16() + ), Err(_) => { - // Offline: fall back to the cached email. let email = creds.email.unwrap_or_else(|| "(unknown)".to_string()); - println!("Signed in as {email} (could not reach siGit Code Cloud to verify)."); + format!("Signed in as {email} (could not reach siGit Code Cloud to verify).") } } - Ok(()) } -/// Print a prompt and read one trimmed line from stdin. -fn prompt(label: &str) -> anyhow::Result<String> { - use std::io::Write; - print!("{label}"); - std::io::stdout().flush()?; - let mut line = String::new(); - std::io::stdin().read_line(&mut line)?; - Ok(line.trim_end_matches(['\n', '\r']).to_string()) +/// Split a `/login` argument into `(email, password)`. The password is the rest +/// of the line after the first whitespace, so it may contain spaces. +pub fn parse_login_args(arg: &str) -> Option<(String, String)> { + let mut parts = arg.trim().splitn(2, char::is_whitespace); + let email = parts.next().unwrap_or("").trim(); + let password = parts.next().unwrap_or("").trim(); + if email.is_empty() || password.is_empty() { + None + } else { + Some((email.to_string(), password.to_string())) + } }
src/chat.rs
+93 -10
index bc86d16..c87012b 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -77,7 +77,7 @@ mod tui { use futures::StreamExt; use onde::inference::{ChatEngine, SamplingConfig, StreamChunk}; - use crate::backend::{InferenceBackend, ToolResult, ToolSpec}; + use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec}; use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items}; use ratatui::{ Frame, @@ -199,6 +199,11 @@ mod tui { switching_model_id: Option<String>, /// (downloaded, expected) bytes — polled every tick during a model switch download_progress: Option<(u64, u64)>, + + // ── Active inference backend ────────────────────────────────────────── + /// The backend serving inference. Swapped in place when the user picks a + /// different model or cloud tier via `/models`. + backend: Arc<dyn InferenceBackend>, } const BANNER_ART: &str = "\ @@ -234,7 +239,8 @@ mod tui { } impl App { - fn new(load_model_name: String, is_remote: bool) -> Self { + fn new(load_model_name: String, backend: Arc<dyn InferenceBackend>) -> Self { + let is_remote = backend.is_remote(); let items = build_model_picker_items(); let tool_calling = items .iter() @@ -279,6 +285,7 @@ mod tui { model_picker_items: items, current_model_name, tool_calling, + backend, } } @@ -541,6 +548,14 @@ mod tui { .bg(Color::Black) .add_modifier(Modifier::BOLD), ), + ModelSource::Cloud => ( + "☁", + "siGit Code Cloud", + Style::default() + .fg(Color::Magenta) + .bg(Color::Black) + .add_modifier(Modifier::BOLD), + ), }; lines.push( @@ -576,6 +591,7 @@ mod tui { ModelSource::HuggingFace => "○", ModelSource::Available => "↓", ModelSource::Fallback => "◎", + ModelSource::Cloud => "☁", }; let source = format!(" [{} {}]", brand_mark, item.source_label); @@ -593,6 +609,7 @@ mod tui { ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black), ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black), ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black), + ModelSource::Cloud => Style::default().fg(Color::Magenta).bg(Color::Black), } }; @@ -672,6 +689,10 @@ mod tui { Status, /// picker UI, or jump straight to model N Models(Option<usize>), + /// `/login <email> <password>` — the raw argument, parsed when executed. + Login(Option<String>), + Logout, + Whoami, Exit, Unknown(String), } @@ -689,6 +710,9 @@ mod tui { "/clear" => SlashCommand::Clear, "/status" => SlashCommand::Status, "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())), + "/login" => SlashCommand::Login(arg.map(str::to_string)), + "/logout" => SlashCommand::Logout, + "/whoami" => SlashCommand::Whoami, "/exit" | "/quit" | "/q" => SlashCommand::Exit, other => SlashCommand::Unknown(other.to_string()), }) @@ -1134,12 +1158,15 @@ mod tui { match cmd { SlashCommand::Help => { app.messages.push(ChatMessage::system( - "/help — show this message\n\ - /models — open the model picker\n\ - /models N — switch to model N\n\ - /clear — wipe conversation history\n\ - /status — show engine status\n\ - /exit — quit chat", + "/help — show this message\n\ + /models — open the model picker\n\ + /models N — switch to model N\n\ + /login E P — sign in to siGit Code Cloud\n\ + /logout — sign out\n\ + /whoami — show the signed-in account\n\ + /clear — wipe conversation history\n\ + /status — show engine status\n\ + /exit — quit chat", )); } SlashCommand::Clear => { @@ -1172,6 +1199,36 @@ mod tui { ))); } Some(model) => { + // ── siGit Code Cloud tier: no local load; sign-in gated ── + if let Some(tier) = model.cloud_tier.clone() { + app.close_model_picker(); + match crate::provider::cloud_tier_provider(&tier) { + Some(provider) => { + let system_prompt = + crate::system_prompt_for_model(true).to_string(); + app.backend = Arc::new(OpenAiBackend::new( + provider.base_url, + provider.api_key, + provider.model, + Some(system_prompt), + )); + app.current_model_name = provider.display_name.clone(); + app.tool_calling = true; + app.messages.push(ChatMessage::system(format!( + "Switched to {}.", + provider.display_name + ))); + } + None => { + app.messages.push(ChatMessage::system( + "siGit Code Cloud needs an account. Use \ + `/login <email> <password>`, or create one at sigit.si.", + )); + } + } + return; + } + if model.cache_health == ModelCacheHealth::Incomplete { app.close_model_picker(); app.messages.push(ChatMessage::system(format!( @@ -1181,6 +1238,10 @@ mod tui { return; } + // Route inference on-device; the loader thread below + // fills the engine the LocalBackend reads from. + app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine))); + let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded { @@ -1244,6 +1305,28 @@ mod tui { } } }, + SlashCommand::Login(arg) => { + let message = match arg.as_deref().and_then(crate::account::parse_login_args) { + Some((email, password)) => { + match crate::account::authenticate(&email, &password).await { + Ok(email) => format!( + "Signed in as {email}. siGit Code Cloud applies to your next session." + ), + Err(error) => format!("Login failed: {error}"), + } + } + None => "usage: /login <email> <password>".to_string(), + }; + app.messages.push(ChatMessage::system(message)); + } + SlashCommand::Logout => { + let message = crate::account::end_session().await; + app.messages.push(ChatMessage::system(message)); + } + SlashCommand::Whoami => { + let message = crate::account::status_line().await; + app.messages.push(ChatMessage::system(message)); + } SlashCommand::Exit => { app.quit = true; } @@ -1377,7 +1460,7 @@ mod tui { load_rx: std_mpsc::Receiver<Result<(), String>>, load_model_name: String, ) -> Result<()> { - let mut app = App::new(load_model_name, backend.is_remote()); + let mut app = App::new(load_model_name, backend); let mut event_stream = EventStream::new(); // 10 fps is plenty for spinners @@ -1612,7 +1695,7 @@ mod tui { let (tx, rx) = mpsc::channel::<InferenceUpdate>(64); app.inference_rx = Some(rx); - let backend_handle = Arc::clone(&backend); + let backend_handle = Arc::clone(&app.backend); let user_text = text.clone(); let tools_enabled = app.tool_calling; tokio::spawn(async move {
src/main.rs
+49 -34
index db8950d..64a21ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -324,7 +324,7 @@ impl SiGitAgent { } let startup_config = self.current_model.lock().unwrap().clone(); - let (max_tokens, tool_calling) = models::build_model_picker_items() + let (max_tokens, tool_calling) = models::local_picker_items() .into_iter() .find(|item| { item.config.model_id == startup_config.model_id @@ -625,7 +625,7 @@ impl SiGitAgent { *guard = None; } - if let Some(item) = models::build_model_picker_items() + if let Some(item) = models::local_picker_items() .iter() .find(|item| item.config.model_id == new_config.model_id) && let Err(err) = setup::save_selected_model(&setup::SelectedModel { @@ -1126,7 +1126,7 @@ impl SiGitAgent { } } - let needs_download = models::build_model_picker_items() + let needs_download = models::local_picker_items() .into_iter() .find(|item| item.config.model_id == model_id) .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded) @@ -1145,7 +1145,7 @@ impl SiGitAgent { .map(|m| m.expected_size_bytes) .unwrap_or(0); - let display_name = models::build_model_picker_items() + let display_name = models::local_picker_items() .into_iter() .find(|item| item.config.model_id == model_id_owned) .map(|item| item.display_name.clone()) @@ -1252,7 +1252,7 @@ impl SiGitAgent { // cached models still take 10-30s to load weights; show a spinner if !needs_download { - let cached_display_name = models::build_model_picker_items() + let cached_display_name = models::local_picker_items() .into_iter() .find(|item| item.config.model_id == model_id) .map(|item| item.display_name.clone()) @@ -1386,7 +1386,7 @@ impl SiGitAgent { const MODEL_CONFIG_ID: &str = "sigit-model"; fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> { - let items = models::build_model_picker_items(); + let items = models::local_picker_items(); let options: Vec<SessionConfigSelectOption> = items .iter() @@ -1434,7 +1434,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon /// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> { - let items = models::build_model_picker_items(); + let items = models::local_picker_items(); items .into_iter() .find(|item| { @@ -1452,6 +1452,10 @@ enum SlashCommand { Clear, Status, Models(Option<usize>), + /// `/login <email> <password>` — the raw argument, parsed when executed. + Login(Option<String>), + Logout, + Whoami, Exit, Unknown(String), } @@ -1469,13 +1473,16 @@ fn parse_slash(input: &str) -> Option<SlashCommand> { "/clear" => SlashCommand::Clear, "/status" => SlashCommand::Status, "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())), + "/login" => SlashCommand::Login(argument.map(str::to_string)), + "/logout" => SlashCommand::Logout, + "/whoami" => SlashCommand::Whoami, "/exit" | "/quit" | "/q" => SlashCommand::Exit, other => SlashCommand::Unknown(other.to_string()), }) } fn format_models_list(current_model: &GgufModelConfig) -> String { - let items = models::build_model_picker_items(); + let items = models::local_picker_items(); if items.is_empty() { return "No local models found. siGit will use the platform default model.".to_string(); } @@ -1548,12 +1555,15 @@ async fn exec_slash_acp( .send_assistant_message( cx, session_id, - "/help - show this message\n\ - /models - list available models\n\ - /models N - switch to model N\n\ - /clear - wipe conversation history\n\ - /status - show engine status\n\ - /exit - end this turn", + "/help - show this message\n\ + /models - list available models\n\ + /models N - switch to model N\n\ + /login E P - sign in to siGit Code Cloud\n\ + /logout - sign out\n\ + /whoami - show the signed-in account\n\ + /clear - wipe conversation history\n\ + /status - show engine status\n\ + /exit - end this turn", ) .ok(); } @@ -1589,7 +1599,7 @@ async fn exec_slash_acp( .ok(); } SlashCommand::Models(Some(number)) => { - let items = models::build_model_picker_items(); + let items = models::local_picker_items(); let index = number.saturating_sub(1); match items.get(index).cloned() { None => { @@ -1672,6 +1682,26 @@ async fn exec_slash_acp( } } } + SlashCommand::Login(argument) => { + let message = match argument.as_deref().and_then(account::parse_login_args) { + Some((email, password)) => match account::authenticate(&email, &password).await { + Ok(email) => format!( + "Signed in as {email}. siGit Code Cloud applies to your next session." + ), + Err(error) => format!("Login failed: {error}"), + }, + None => "usage: /login <email> <password>".to_string(), + }; + agent.send_assistant_message(cx, session_id, message).ok(); + } + SlashCommand::Logout => { + let message = account::end_session().await; + agent.send_assistant_message(cx, session_id, message).ok(); + } + SlashCommand::Whoami => { + let message = account::status_line().await; + agent.send_assistant_message(cx, session_id, message).ok(); + } SlashCommand::Exit => { agent .send_assistant_message( @@ -1813,7 +1843,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> let config = startup_selection .as_ref() .and_then(|selection| { - models::build_model_picker_items() + models::local_picker_items() .into_iter() .find(|item| { selection @@ -1840,7 +1870,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> // std::sync::mpsc on a real thread so model loading can't starve the TUI let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>(); - let tool_calling = models::build_model_picker_items() + let tool_calling = models::local_picker_items() .iter() .find(|item| item.config.model_id == config.model_id) .map(|item| item.tool_calling) @@ -1925,7 +1955,7 @@ async fn run_acp_server() -> anyhow::Result<()> { .as_ref() .and_then(|selection| { selection.selected_model.as_ref().and_then(|selected| { - models::build_model_picker_items() + models::local_picker_items() .into_iter() .find(|item| { item.config.model_id == selected.model_id @@ -1940,7 +1970,7 @@ async fn run_acp_server() -> anyhow::Result<()> { }) .unwrap_or_else(GgufModelConfig::qwen25_3b); - let needs_download = models::build_model_picker_items() + let needs_download = models::local_picker_items() .iter() .find(|item| item.config.model_id == config.model_id) .map(|item| item.cache_health != setup::ModelCacheHealth::Complete) @@ -2069,21 +2099,6 @@ async fn run_acp_server() -> anyhow::Result<()> { #[tokio::main] async fn main() -> anyhow::Result<()> { - // Account subcommands run before the TUI/ACP split. They are plain CLI verbs. - if let Some(command) = std::env::args().nth(1) { - match command.as_str() { - "login" | "logout" | "whoami" => { - init_logging(false); - return match command.as_str() { - "login" => account::login().await, - "logout" => account::logout().await, - _ => account::whoami().await, - }; - } - _ => {} - } - } - let is_tty = std::io::stdin().is_terminal(); if is_tty {
src/models.rs
+44 -1
index 74b44ae..d9095da 100644 --- a/src/models.rs +++ b/src/models.rs @@ -16,6 +16,8 @@ pub(crate) enum ModelSource { /// not downloaded yet — selecting it triggers a download into the app-group cache. Available, Fallback, + /// a siGit Code Cloud tier (runs over the network, not on-device). + Cloud, } #[derive(Clone)] @@ -29,6 +31,8 @@ pub(crate) struct ModelPickerItem { pub(crate) source: ModelSource, pub(crate) cache_health: ModelCacheHealth, + /// `Some(tier)` for a siGit Code Cloud entry; `None` for an on-device model. + pub(crate) cloud_tier: Option<String>, } // ── Model ID → GgufModelConfig mapping ──────────────────────────────────────── @@ -105,10 +109,11 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { source: ModelSource::Available, cache_health: ModelCacheHealth::NotDownloaded, + cloud_tier: None, }); } - // ── 3. Fallback ────────────────────────────────────────────────────── + // ── 3. Fallback (on-device default when nothing else is present) ───── if items.is_empty() { let config = GgufModelConfig::platform_default(); let tool_calling = is_tool_calling(&config.model_id); @@ -124,6 +129,33 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { source: ModelSource::Fallback, cache_health: ModelCacheHealth::Complete, + cloud_tier: None, + }); + } + + // ── 4. siGit Code Cloud tiers (always offered; sign-in gated at select) ─ + for tier in crate::provider::CLOUD_TIERS { + let label = crate::provider::cloud_tier_label(tier); + // Synthetic config: a `sigit-cloud:<tier>` id never collides with a real + // HuggingFace id (no `/`), so on-device matching code stays inert. + let config = GgufModelConfig { + model_id: format!("sigit-cloud:{tier}"), + files: Vec::new(), + tok_model_id: None, + display_name: label.clone(), + approx_memory: "Cloud".to_string(), + chat_template: None, + }; + items.push(ModelPickerItem { + display_name: label, + description: "siGit Code Cloud".to_string(), + tool_calling: true, + max_tokens: 4096, + config, + source_label: "siGit Code Cloud".to_string(), + source: ModelSource::Cloud, + cache_health: ModelCacheHealth::Complete, + cloud_tier: Some((*tier).to_string()), }); } @@ -136,6 +168,16 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { items } +/// Picker items restricted to on-device models (no cloud tiers). Used by the +/// model-loading and ACP session-config paths, which only handle local GGUF +/// models. The cloud tiers are an interactive TUI-picker feature. +pub(crate) fn local_picker_items() -> Vec<ModelPickerItem> { + build_model_picker_items() + .into_iter() + .filter(|item| item.cloud_tier.is_none()) + .collect() +} + // ── Internal helpers ────────────────────────────────────────────────────────── fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> { @@ -164,5 +206,6 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker ModelSource::HuggingFace }, cache_health: model.cache_health, + cloud_tier: None, }) }
src/provider.rs
+38 -26
index 923a9e3..37d37d7 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -16,9 +16,33 @@ use serde::Deserialize; /// (dev: `http://localhost:8090/v1`). const DEFAULT_CLOUD_URL: &str = "https://cloud.ondeinference.com/v1"; -/// Default quality tier when the user hasn't chosen one. Override with `SIGIT_TIER` -/// (`fast` | `balanced` | `large`). -const DEFAULT_TIER: &str = "balanced"; +/// The cloud quality tiers, in display order. Always offered in `/models`; +/// selecting one requires a signed-in account. +pub const CLOUD_TIERS: &[&str] = &["fast", "balanced", "large"]; + +/// Base URL of the siGit Code Cloud inference endpoint. Override with +/// `SIGIT_CLOUD_URL` (dev: `http://localhost:8090/v1`). +pub fn cloud_base_url() -> String { + std::env::var("SIGIT_CLOUD_URL").unwrap_or_else(|_| DEFAULT_CLOUD_URL.to_string()) +} + +/// Display label for a tier, e.g. `siGit Code Cloud · Balanced`. +pub fn cloud_tier_label(tier: &str) -> String { + format!("siGit Code Cloud · {}", tier_title(tier)) +} + +/// Build a siGit Code Cloud provider for `tier`, if the user is signed in. +/// Returns `None` when there is no stored session, so the caller can prompt for +/// login rather than silently failing. +pub fn cloud_tier_provider(tier: &str) -> Option<ProviderConfig> { + let token = crate::credentials::load_token()?; + Some(ProviderConfig { + display_name: cloud_tier_label(tier), + base_url: cloud_base_url(), + api_key: token, + model: tier_to_model(tier), + }) +} /// Map a neutral tier name to the model id sent on the wire. Unknown values pass /// through unchanged so an explicit model id still works. @@ -58,35 +82,23 @@ fn tier_title(tier: &str) -> String { /// Default model id used when an environment-configured provider omits one. const DEFAULT_ENV_MODEL: &str = "onde-large"; -/// Resolve the active provider, or `None` to run on-device. +/// Resolve the startup provider, or `None` to run on-device. +/// +/// This is only the explicit override (env or `providers.toml`), for power users +/// and BYO endpoints. Being signed in does **not** auto-select the cloud: model +/// choice is separate from identity, and the user picks a model or tier in +/// `/models`. With no override, siGit Code is local-first. pub fn active_provider() -> Option<ProviderConfig> { - // 1. Explicit override (env or providers.toml). if let Some(config) = from_env() { return Some(config); } match from_config_file() { - Ok(Some(config)) => return Some(config), - Ok(None) => {} - Err(error) => log::warn!("provider: ignoring providers.toml: {error}"), + Ok(config) => config, + Err(error) => { + log::warn!("provider: ignoring providers.toml: {error}"); + None + } } - // 2. siGit Code Cloud, used when logged in. - from_login() - // 3. Otherwise None, meaning on-device. -} - -/// The siGit Code Cloud provider, used once the user has logged in. The session -/// token is the credential. -fn from_login() -> Option<ProviderConfig> { - let token = crate::credentials::load_token()?; - let base_url = - std::env::var("SIGIT_CLOUD_URL").unwrap_or_else(|_| DEFAULT_CLOUD_URL.to_string()); - let tier = std::env::var("SIGIT_TIER").unwrap_or_else(|_| DEFAULT_TIER.to_string()); - Some(ProviderConfig { - display_name: format!("siGit Code Cloud · {}", tier_title(&tier)), - base_url, - api_key: token, - model: tier_to_model(&tier), - }) } /// Provider from environment variables, if both URL and key are present.