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.