@setoelkahfi / sigit / commits / adbd317

Add siGit Code Cloud

Seto Elkahfi committed Jun 22, 2026 at 19:44 UTC adbd3172ae13a5f16fbdd7e3a2c95e3d2d5e08ef
8 files changed +1102 -40
Cargo.lock
+80 -5
index 0bddf82..781357f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3647,7 +3647,7 @@ dependencies = [ "tokio-tungstenite", "toktrie", "toktrie_hf_tokenizers", - "toml", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "urlencoding", @@ -4586,6 +4586,27 @@ 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" @@ -5073,6 +5094,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -5184,6 +5214,7 @@ version = "1.0.4" dependencies = [ "agent-client-protocol", "anyhow", + "async-trait", "crossterm 0.29.0", "futures", "libc", @@ -5192,9 +5223,12 @@ dependencies = [ "ratatui", "regex", "reqwest 0.12.28", + "rpassword", + "serde", "serde_json", "tokio", "tokio-util", + "toml 0.8.23", "tracing-subscriber", "uuid 1.23.3", ] @@ -6179,6 +6213,18 @@ dependencies = [ "toktrie", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -6187,13 +6233,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -6203,6 +6258,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -6212,6 +6281,12 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -6528,7 +6603,7 @@ dependencies = [ "serde", "tempfile", "textwrap 0.16.2", - "toml", + "toml 0.9.12+spec-1.1.0", "uniffi_internal_macros", "uniffi_meta", "uniffi_pipeline", @@ -6585,7 +6660,7 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "toml", + "toml 0.9.12+spec-1.1.0", "uniffi_meta", ]
Cargo.toml
+5 -1
index 3944653..d5788e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,11 @@ anyhow = "1" libc = "0.2" log = "0.4" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +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", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } uuid = { version = "1", features = ["v4"] }
src/account.rs
+160
new file mode 100644 index 0000000..1dee99c --- /dev/null +++ b/src/account.rs @@ -0,0 +1,160 @@ +//! Account commands: `login`, `logout`, `whoami`. +//! +//! These authenticate against the siGit account API and store a session token +//! locally. The token is the credential used for siGit Code Cloud requests. +//! +//! Base URL: `$SIGIT_API_URL`, else `https://sigit.si`. + +use serde::Deserialize; + +use crate::credentials::{self, Credentials}; + +/// Default account API host. Override with `SIGIT_API_URL` (dev: `http://localhost:8088`). +const DEFAULT_API_URL: &str = "https://sigit.si"; + +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>, +} + +#[derive(Debug, Deserialize)] +struct MeResponse { + #[serde(default)] + 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"); + } + + let url = format!("{}/api/v1/users/sign_in", base.trim_end_matches('/')); + let response = reqwest::Client::new() + .post(&url) + .json(&serde_json::json!({ "email": email.trim(), "password": password })) + .send() + .await + .map_err(|error| anyhow::anyhow!("could not reach siGit Code Cloud: {error}"))?; + + let status = response.status(); + let parsed: SignInResponse = 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(()); + } + + // 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()); +} + +/// `sigit logout`: clear the local session, notifying the server best-effort. +pub async fn logout() -> anyhow::Result<()> { + 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 _ = reqwest::Client::new() + .delete(&url) + .bearer_auth(&token) + .send() + .await; + } + if credentials::clear() { + println!("✓ Signed out of siGit Code Cloud."); + } else { + println!("Not signed in."); + } + Ok(()) +} + +/// `sigit whoami`: show the signed-in account, verifying the token if reachable. +pub async fn whoami() -> anyhow::Result<()> { + let Some(creds) = credentials::load() else { + println!("Not signed in. Run `sigit login` to use siGit Code Cloud."); + return Ok(()); + }; + + let url = format!("{}/api/v1/me", api_base().trim_end_matches('/')); + match reqwest::Client::new() + .get(&url) + .bearer_auth(&creds.access_token) + .send() + .await + { + Ok(response) if response.status().is_success() => { + let email = response + .json::<MeResponse>() + .await + .ok() + .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() + ); + } + 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)."); + } + } + 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()) +}
src/backend.rs
+434
new file mode 100644 index 0000000..3f08994 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,434 @@ +//! Inference backend abstraction. +//! +//! The agent loop only needs to send a turn (optionally with tools) and return +//! tool results. This module defines that seam as the `InferenceBackend` trait +//! plus a few neutral types, with two implementations: +//! +//! - `LocalBackend` runs on-device through the `onde` crate (`ChatEngine`). +//! - `OpenAiBackend` talks to any OpenAI-compatible HTTP endpoint, configured by +//! `base_url`, `api_key`, and `model`. +//! +//! The trait exposes neither `onde` nor OpenAI types, so the loop does not depend +//! on a specific backend. + +use std::sync::Arc; + +use async_trait::async_trait; +use onde::inference::{ChatEngine, ToolDefinition}; +use serde::Deserialize; +use tokio::sync::Mutex; + +// ── Neutral types ─────────────────────────────────────────────────────────────── + +/// A tool the model may call, in a provider-neutral form. `parameters_schema` is +/// a JSON Schema encoded as a string (matching how siGit already declares tools). +#[derive(Debug, Clone)] +pub struct ToolSpec { + pub name: String, + pub description: String, + pub parameters_schema: String, +} + +/// A tool call requested by the model. +#[derive(Debug, Clone)] +pub struct ToolCall { + pub id: String, + pub name: String, + /// Arguments as a JSON-encoded string. + pub arguments: String, +} + +/// The output of executing one tool call, fed back to the model. +#[derive(Debug, Clone)] +pub struct ToolResult { + pub tool_call_id: String, + pub content: String, +} + +/// The result of one assistant turn: free text and/or tool calls. +#[derive(Debug, Clone, Default)] +pub struct TurnResult { + pub text: String, + pub tool_calls: Vec<ToolCall>, +} + +/// Backend errors are plain strings. Callers map them to ACP errors. +pub type BackendError = String; + +// ── The trait ─────────────────────────────────────────────────────────────────── + +/// A swappable inference backend driving siGit Code's agent loop. +#[async_trait] +pub trait InferenceBackend: Send + Sync { + /// Start an assistant turn from a new user message, offering `tools`. + async fn send_message_with_tools( + &self, + text: &str, + tools: &[ToolSpec], + ) -> Result<TurnResult, BackendError>; + + /// Continue the turn by returning tool results. `tools` may be `None` on the + /// final round to force a text answer. + async fn send_tool_results( + &self, + results: Vec<ToolResult>, + tools: Option<&[ToolSpec]>, + ) -> Result<TurnResult, BackendError>; + + /// Whether inference runs over the network (a configured provider) rather + /// than on-device. Drives UI labelling so the displayed model can't claim a + /// local model while requests actually go to the cloud. + fn is_remote(&self) -> bool; +} + +// ── Local backend (onde ChatEngine) ────────────────────────────────────────────── + +/// On-device inference. A thin adapter over `onde::ChatEngine`. +pub struct LocalBackend { + engine: Arc<ChatEngine>, +} + +impl LocalBackend { + pub fn new(engine: Arc<ChatEngine>) -> Self { + Self { engine } + } +} + +fn to_onde_tools(tools: &[ToolSpec]) -> Vec<ToolDefinition> { + tools + .iter() + .map(|tool| ToolDefinition { + name: tool.name.clone(), + description: tool.description.clone(), + parameters_schema: tool.parameters_schema.clone(), + }) + .collect() +} + +#[async_trait] +impl InferenceBackend for LocalBackend { + async fn send_message_with_tools( + &self, + text: &str, + tools: &[ToolSpec], + ) -> Result<TurnResult, BackendError> { + let onde_tools = to_onde_tools(tools); + let result = self + .engine + .send_message_with_tools(text, &onde_tools) + .await + .map_err(|error| error.to_string())?; + Ok(onde_result_to_turn(result)) + } + + async fn send_tool_results( + &self, + results: Vec<ToolResult>, + tools: Option<&[ToolSpec]>, + ) -> Result<TurnResult, BackendError> { + let onde_results: Vec<onde::inference::ToolResult> = results + .into_iter() + .map(|result| onde::inference::ToolResult { + tool_call_id: result.tool_call_id, + content: result.content, + }) + .collect(); + let onde_tools = tools.map(to_onde_tools); + let result = self + .engine + .send_tool_results(onde_results, onde_tools.as_deref()) + .await + .map_err(|error| error.to_string())?; + Ok(onde_result_to_turn(result)) + } + + fn is_remote(&self) -> bool { + false + } +} + +/// Convert an `onde` tool-aware result into the neutral [`TurnResult`]. +fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult { + TurnResult { + text: result.text, + tool_calls: result + .tool_calls + .into_iter() + .map(|call| ToolCall { + id: call.id, + name: call.function_name, + arguments: call.arguments, + }) + .collect(), + } +} + +// ── OpenAI-compatible backend ───────────────────────────────────────────────────── + +/// Inference against any OpenAI-compatible Chat Completions endpoint. +/// +/// Conversation state is held client-side and replayed on every request, so the +/// endpoint can be stateless. Standard OpenAI function-calling is used end to +/// end (`tools`, `choices[].message.tool_calls`, `role: "tool"` follow-ups). +pub struct OpenAiBackend { + base_url: String, + api_key: String, + model: String, + http: reqwest::Client, + /// The full message list sent on each request (system + turns + tool results). + history: Mutex<Vec<serde_json::Value>>, +} + +impl OpenAiBackend { + /// Build a backend for `{base_url, api_key, model}`, seeding the optional + /// system prompt. `base_url` should include the API root (e.g. ending in + /// `/v1`); the chat path is appended. + pub fn new( + base_url: impl Into<String>, + api_key: impl Into<String>, + model: impl Into<String>, + system_prompt: Option<String>, + ) -> Self { + let mut history = Vec::new(); + if let Some(prompt) = system_prompt { + history.push(serde_json::json!({ "role": "system", "content": prompt })); + } + Self { + base_url: base_url.into(), + api_key: api_key.into(), + model: model.into(), + http: reqwest::Client::new(), + history: Mutex::new(history), + } + } + + fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> { + tools + .iter() + .map(|tool| { + // parameters_schema is a JSON string; parse it, defaulting to an + // empty object schema if malformed. + let parameters: serde_json::Value = serde_json::from_str(&tool.parameters_schema) + .unwrap_or_else(|_| serde_json::json!({ "type": "object", "properties": {} })); + serde_json::json!({ + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": parameters, + } + }) + }) + .collect() + } + + /// POST the current history (plus `tools`) and apply the assistant reply to + /// history, returning the neutral turn result. + async fn complete(&self, tools: Option<&[ToolSpec]>) -> Result<TurnResult, BackendError> { + let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); + + let mut body = serde_json::json!({ + "model": self.model, + "messages": *self.history.lock().await, + "stream": false, + }); + if let Some(tools) = tools + && !tools.is_empty() + { + body["tools"] = serde_json::Value::Array(Self::tools_json(tools)); + } + + let response = self + .http + .post(&url) + .bearer_auth(&self.api_key) + .json(&body) + .send() + .await + .map_err(|error| format!("request to {url} failed: {error}"))?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(format!("endpoint returned {status}: {detail}")); + } + + let parsed: ChatCompletion = response + .json() + .await + .map_err(|error| format!("response parse error: {error}"))?; + + let message = parsed + .choices + .into_iter() + .next() + .map(|choice| choice.message) + .ok_or_else(|| "endpoint returned no choices".to_string())?; + + let text = message.content.clone().unwrap_or_default(); + let tool_calls: Vec<ToolCall> = message + .tool_calls + .iter() + .flatten() + .map(|call| ToolCall { + id: call.id.clone(), + name: call.function.name.clone(), + arguments: call.function.arguments.clone(), + }) + .collect(); + + // Record the assistant turn so later tool results have context. + self.history.lock().await.push(message.into_history_value()); + + Ok(TurnResult { text, tool_calls }) + } +} + +#[async_trait] +impl InferenceBackend for OpenAiBackend { + async fn send_message_with_tools( + &self, + text: &str, + tools: &[ToolSpec], + ) -> Result<TurnResult, BackendError> { + self.history + .lock() + .await + .push(serde_json::json!({ "role": "user", "content": text })); + self.complete(Some(tools)).await + } + + async fn send_tool_results( + &self, + results: Vec<ToolResult>, + tools: Option<&[ToolSpec]>, + ) -> Result<TurnResult, BackendError> { + { + let mut history = self.history.lock().await; + for result in results { + history.push(serde_json::json!({ + "role": "tool", + "tool_call_id": result.tool_call_id, + "content": result.content, + })); + } + } + self.complete(tools).await + } + + fn is_remote(&self) -> bool { + true + } +} + +// ── OpenAI response shapes ──────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct ChatCompletion { + #[serde(default)] + choices: Vec<CompletionChoice>, +} + +#[derive(Debug, Deserialize)] +struct CompletionChoice { + message: ResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct ResponseMessage { + #[serde(default)] + content: Option<String>, + #[serde(default)] + tool_calls: Option<Vec<ResponseToolCall>>, +} + +impl ResponseMessage { + /// Reconstruct the assistant message for replay in history, preserving any + /// tool calls so the follow-up request is well-formed. + fn into_history_value(self) -> serde_json::Value { + let mut message = serde_json::json!({ "role": "assistant" }); + message["content"] = match self.content { + Some(text) => serde_json::Value::String(text), + None => serde_json::Value::Null, + }; + if let Some(tool_calls) = self.tool_calls { + message["tool_calls"] = serde_json::json!( + tool_calls + .into_iter() + .map(|call| serde_json::json!({ + "id": call.id, + "type": "function", + "function": { + "name": call.function.name, + "arguments": call.function.arguments, + } + })) + .collect::<Vec<_>>() + ); + } + message + } +} + +#[derive(Debug, Deserialize)] +struct ResponseToolCall { + id: String, + function: ResponseFunction, +} + +#[derive(Debug, Deserialize)] +struct ResponseFunction { + name: String, + #[serde(default)] + arguments: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tools_json_wraps_function_schema() { + let tools = vec![ToolSpec { + name: "read_file".to_string(), + description: "Read a file".to_string(), + parameters_schema: r#"{"type":"object","properties":{"path":{"type":"string"}}}"# + .to_string(), + }]; + let json = OpenAiBackend::tools_json(&tools); + assert_eq!(json[0]["type"], "function"); + assert_eq!(json[0]["function"]["name"], "read_file"); + assert_eq!(json[0]["function"]["parameters"]["properties"]["path"]["type"], "string"); + } + + #[test] + fn malformed_schema_falls_back_to_empty_object() { + let tools = vec![ToolSpec { + name: "x".to_string(), + description: String::new(), + parameters_schema: "not json".to_string(), + }]; + let json = OpenAiBackend::tools_json(&tools); + assert_eq!(json[0]["function"]["parameters"]["type"], "object"); + } + + #[test] + fn assistant_message_with_tool_calls_round_trips() { + let message = ResponseMessage { + content: None, + tool_calls: Some(vec![ResponseToolCall { + id: "call_1".to_string(), + function: ResponseFunction { + name: "read_file".to_string(), + arguments: r#"{"path":"a.rs"}"#.to_string(), + }, + }]), + }; + let value = message.into_history_value(); + assert_eq!(value["role"], "assistant"); + assert!(value["content"].is_null()); + assert_eq!(value["tool_calls"][0]["id"], "call_1"); + assert_eq!(value["tool_calls"][0]["type"], "function"); + assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file"); + } +}
src/chat.rs
+32 -23
index fb0370f..bc86d16 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -75,8 +75,9 @@ mod tui { use anyhow::Result; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use futures::StreamExt; - use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult}; + use onde::inference::{ChatEngine, SamplingConfig, StreamChunk}; + use crate::backend::{InferenceBackend, ToolResult, ToolSpec}; use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items}; use ratatui::{ Frame, @@ -233,13 +234,22 @@ mod tui { } impl App { - fn new(load_model_name: String) -> Self { + fn new(load_model_name: String, is_remote: bool) -> Self { let items = build_model_picker_items(); let tool_calling = items .iter() .find(|m| m.display_name == load_model_name) .map(|m| m.tool_calling) .unwrap_or(true); + // For a remote provider the passed-in name is authoritative; the + // persisted local selection must not override it (or the title would + // show an on-device model while requests go to the cloud). + let current_model_name = if is_remote { + load_model_name.clone() + } else { + crate::setup::load_selected_model_name() + .unwrap_or_else(|| load_model_name.clone()) + }; Self { messages: Vec::new(), input: String::new(), @@ -267,8 +277,7 @@ mod tui { show_model_picker: false, model_picker_index: 0, model_picker_items: items, - current_model_name: crate::setup::load_selected_model_name() - .unwrap_or_else(|| load_model_name.clone()), + current_model_name, tool_calling, } } @@ -1250,10 +1259,10 @@ mod tui { /// cap tool rounds so a confused model can't loop forever const MAX_TOOL_ROUNDS: usize = 10; - fn build_onde_tools() -> Vec<ToolDefinition> { + fn build_tool_specs() -> Vec<ToolSpec> { crate::tools::all_tools() .into_iter() - .map(|t| ToolDefinition { + .map(|t| ToolSpec { name: t.name.to_string(), description: t.description.to_string(), parameters_schema: t.parameters_schema.to_string(), @@ -1264,21 +1273,21 @@ mod tui { /// run the tool-calling loop off the main thread, posting updates via `tx`. /// dropping `tx` signals completion to the event loop. async fn run_inference_task( - engine: Arc<ChatEngine>, + backend: Arc<dyn InferenceBackend>, text: String, tx: mpsc::Sender<InferenceUpdate>, tools_enabled: bool, ) { - let onde_tools = if tools_enabled { - build_onde_tools() + let tools = if tools_enabled { + build_tool_specs() } else { vec![] }; - let mut result = match engine.send_message_with_tools(&text, &onde_tools).await { + let mut result = match backend.send_message_with_tools(&text, &tools).await { Ok(r) => r, Err(err) => { - let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; + let _ = tx.send(InferenceUpdate::Error(err)).await; return; } }; @@ -1294,15 +1303,13 @@ mod tui { for tc in &result.tool_calls { log::info!( " → {}({})", - tc.function_name, + tc.name, tc.arguments.chars().take(120).collect::<String>() ); - let _ = tx - .send(InferenceUpdate::ToolUse(tc.function_name.clone())) - .await; + let _ = tx.send(InferenceUpdate::ToolUse(tc.name.clone())).await; - let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await; + let output = crate::tools::execute_tool(&tc.name, &tc.arguments).await; log::info!(" ← {} chars", output.len()); tool_results.push(ToolResult { @@ -1313,15 +1320,15 @@ mod tui { // on the last round, pass no tools so the model must produce text let next_tools = if round < MAX_TOOL_ROUNDS { - Some(onde_tools.as_slice()) + Some(tools.as_slice()) } else { None }; - match engine.send_tool_results(tool_results, next_tools).await { + match backend.send_tool_results(tool_results, next_tools).await { Ok(r) => result = r, Err(err) => { - let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; + let _ = tx.send(InferenceUpdate::Error(err)).await; return; } } @@ -1356,19 +1363,21 @@ mod tui { pub async fn run_with<B: ratatui::backend::Backend>( terminal: &mut ratatui::Terminal<B>, engine: Arc<ChatEngine>, + backend: Arc<dyn InferenceBackend>, load_rx: std_mpsc::Receiver<Result<(), String>>, load_model_name: String, ) -> Result<()> { - event_loop(terminal, engine, load_rx, load_model_name).await + event_loop(terminal, engine, backend, load_rx, load_model_name).await } async fn event_loop<B: ratatui::backend::Backend>( terminal: &mut ratatui::Terminal<B>, engine: Arc<ChatEngine>, + backend: Arc<dyn InferenceBackend>, load_rx: std_mpsc::Receiver<Result<(), String>>, load_model_name: String, ) -> Result<()> { - let mut app = App::new(load_model_name); + let mut app = App::new(load_model_name, backend.is_remote()); let mut event_stream = EventStream::new(); // 10 fps is plenty for spinners @@ -1603,11 +1612,11 @@ mod tui { let (tx, rx) = mpsc::channel::<InferenceUpdate>(64); app.inference_rx = Some(rx); - let engine_handle = Arc::clone(&engine); + let backend_handle = Arc::clone(&backend); let user_text = text.clone(); let tools_enabled = app.tool_calling; tokio::spawn(async move { - run_inference_task(engine_handle, user_text, tx, tools_enabled).await; + run_inference_task(backend_handle, user_text, tx, tools_enabled).await; }); } }
src/credentials.rs
+107
new file mode 100644 index 0000000..10af020 --- /dev/null +++ b/src/credentials.rs @@ -0,0 +1,107 @@ +//! Local credential store. +//! +//! Holds the session token from `sigit login`, used to authenticate siGit Code +//! Cloud requests. Stored as TOML at `$SIGIT_CONFIG_DIR/credentials.toml` or +//! `~/.config/sigit/credentials.toml`, with `0600` permissions on Unix. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// The persisted session, written on login and cleared on logout. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Credentials { + /// Bearer token issued by sigit.si. + pub access_token: String, + /// Account email, kept for `whoami` display. + #[serde(default)] + pub email: Option<String>, +} + +/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. +fn config_dir() -> Option<PathBuf> { + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { + return Some(PathBuf::from(dir)); + } + let home = std::env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".config/sigit")) +} + +fn credentials_path() -> Option<PathBuf> { + config_dir().map(|dir| dir.join("credentials.toml")) +} + +/// Load stored credentials, or `None` if not logged in. +pub fn load() -> Option<Credentials> { + let path = credentials_path()?; + let contents = std::fs::read_to_string(&path).ok()?; + match toml::from_str::<Credentials>(&contents) { + Ok(credentials) if !credentials.access_token.trim().is_empty() => Some(credentials), + _ => None, + } +} + +/// Convenience: the bearer token alone, if logged in. +pub fn load_token() -> Option<String> { + load().map(|credentials| credentials.access_token) +} + +/// Persist credentials, creating the config dir and restricting permissions. +pub fn store(credentials: &Credentials) -> Result<(), String> { + let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?; + std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?; + let path = dir.join("credentials.toml"); + let body = + toml::to_string(credentials).map_err(|error| format!("serialize credentials: {error}"))?; + std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?; + restrict_permissions(&path); + Ok(()) +} + +/// Remove stored credentials. Returns `true` if a file was deleted. +pub fn clear() -> bool { + match credentials_path() { + Some(path) if path.exists() => std::fs::remove_file(&path).is_ok(), + _ => false, + } +} + +#[cfg(unix)] +fn restrict_permissions(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); +} + +#[cfg(not(unix))] +fn restrict_permissions(_path: &std::path::Path) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_credentials_via_temp_dir() { + let dir = std::env::temp_dir().join(format!("sigit_creds_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // SAFETY: single-threaded test; restores below. + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; + + assert!(load().is_none()); + store(&Credentials { + access_token: "tok_123".to_string(), + email: Some("dev@sigit.si".to_string()), + }) + .unwrap(); + + let loaded = load().expect("credentials present"); + assert_eq!(loaded.access_token, "tok_123"); + assert_eq!(loaded.email.as_deref(), Some("dev@sigit.si")); + assert_eq!(load_token().as_deref(), Some("tok_123")); + + assert!(clear()); + assert!(load().is_none()); + + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; + let _ = std::fs::remove_dir_all(&dir); + } +}
src/main.rs
+66 -11
index 5f3556f..db8950d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,8 +28,12 @@ //! } //! ``` +mod account; +mod backend; mod chat; +mod credentials; mod models; +mod provider; mod setup; mod tools; @@ -53,6 +57,8 @@ use agent_client_protocol::schema::{ }; use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder}; use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult}; + +use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -1834,28 +1840,62 @@ 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 loader_engine = Arc::clone(&engine); let tool_calling = models::build_model_picker_items() .iter() .find(|item| item.config.model_id == config.model_id) .map(|item| item.tool_calling) .unwrap_or(false); - let system_prompt = system_prompt_for_model(tool_calling).to_string(); - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime"); - let result = - rt.block_on(loader_engine.load_gguf_model(config, Some(system_prompt), Some(sampling))); - let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); - }); + + // Pick the inference backend: a configured provider if present, else on-device. + let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) = + match provider::active_provider() { + Some(provider) => { + log::info!( + "inference: using {} (model {}) at {}", + provider.display_name, + provider.model, + provider.base_url + ); + // No local model to load; the endpoint is ready immediately. + let _ = load_tx.send(Ok(())); + let label = provider.display_name.clone(); + let backend = Arc::new(OpenAiBackend::new( + provider.base_url, + provider.api_key, + provider.model, + Some(SYSTEM_PROMPT.to_string()), + )) as Arc<dyn InferenceBackend>; + (backend, label) + } + None => { + // On-device: load the local GGUF model on a real thread. + let loader_engine = Arc::clone(&engine); + let system_prompt = system_prompt_for_model(tool_calling).to_string(); + std::thread::spawn(move || { + let rt = + tokio::runtime::Runtime::new().expect("failed to create loader runtime"); + let result = rt.block_on(loader_engine.load_gguf_model( + config, + Some(system_prompt), + Some(sampling), + )); + let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); + }); + let backend = + Arc::new(LocalBackend::new(Arc::clone(&engine))) as Arc<dyn InferenceBackend>; + (backend, startup_model_name) + } + }; crossterm::terminal::enable_raw_mode()?; let mut tty = BufWriter::new(tty); crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?; - let backend = ratatui::backend::CrosstermBackend::new(tty); - let mut terminal = ratatui::Terminal::new(backend)?; + let term_backend = ratatui::backend::CrosstermBackend::new(tty); + let mut terminal = ratatui::Terminal::new(term_backend)?; // polls load_rx with try_recv() each tick, no blocking - let chat_result = chat::run_with(&mut terminal, engine, load_rx, startup_model_name).await; + let chat_result = + chat::run_with(&mut terminal, engine, inference_backend, load_rx, startup_model_name).await; // cleanup fd because backend's writer is private crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?; @@ -2029,6 +2069,21 @@ 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/provider.rs
+218
new file mode 100644 index 0000000..923a9e3 --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,218 @@ +//! Inference provider configuration. +//! +//! Decides which backend serves inference. Resolution order, first match wins: +//! +//! 1. Override: `OPENAI_BASE_URL` + `OPENAI_API_KEY`, or the active profile in +//! `~/.config/sigit/providers.toml`. +//! 2. siGit Code Cloud: used when the user is logged in (`sigit login`). The +//! endpoint and tier are built in, and the session token is the credential. +//! 3. On-device: no login and no override, so inference runs locally. + +use std::path::PathBuf; + +use serde::Deserialize; + +/// Default siGit Code Cloud inference endpoint. Override with `SIGIT_CLOUD_URL` +/// (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"; + +/// 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. +fn tier_to_model(tier: &str) -> String { + match tier.trim().to_lowercase().as_str() { + "fast" => "onde-fast", + "balanced" => "onde-balanced", + "large" => "onde-large", + other => other, + } + .to_string() +} + +/// A resolved inference provider: everything needed to build an OpenAI-compatible +/// client. Deliberately free of any Onde/smbCloud-specific fields. +#[derive(Debug, Clone)] +pub struct ProviderConfig { + /// Human-facing name shown in the UI (e.g. `siGit Code Cloud · Balanced`). + pub display_name: String, + /// API root, e.g. `https://cloud.ondeinference.com/v1`. + pub base_url: String, + pub api_key: String, + /// Model id sent to the endpoint, e.g. `onde-balanced` or `gpt-4o-mini`. + pub model: String, +} + +/// Title-case a tier name for display (`balanced` → `Balanced`). +fn tier_title(tier: &str) -> String { + let tier = tier.trim(); + let mut chars = tier.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), + None => "Balanced".to_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. +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}"), + } + // 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. +fn from_env() -> Option<ProviderConfig> { + let base_url = non_empty(std::env::var("OPENAI_BASE_URL").ok())?; + // A base URL with no key is almost always a mistake. Warn instead of + // silently falling back to on-device, which looks like the cloud failed. + let Some(api_key) = non_empty(std::env::var("OPENAI_API_KEY").ok()) else { + log::warn!( + "provider: OPENAI_BASE_URL is set but OPENAI_API_KEY is empty/missing; \ + staying on-device. Set OPENAI_API_KEY to use the remote provider." + ); + return None; + }; + let model = non_empty(std::env::var("SIGIT_MODEL").ok()) + .unwrap_or_else(|| DEFAULT_ENV_MODEL.to_string()); + Some(ProviderConfig { + display_name: format!("{model} (custom endpoint)"), + base_url, + api_key, + model, + }) +} + +// ── providers.toml ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct ProvidersFile { + /// Name of the profile to use. + active: Option<String>, + #[serde(default)] + provider: Vec<ProviderEntry>, +} + +#[derive(Debug, Deserialize)] +struct ProviderEntry { + name: String, + base_url: String, + api_key: String, + model: String, +} + +/// Path to the providers file: `$SIGIT_CONFIG_DIR` or `~/.config/sigit/providers.toml`. +fn config_path() -> Option<PathBuf> { + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { + return Some(PathBuf::from(dir).join("providers.toml")); + } + let home = std::env::var("HOME").ok()?; + Some(PathBuf::from(home).join(".config/sigit/providers.toml")) +} + +/// Load the active profile from `providers.toml`, if the file exists and names one. +fn from_config_file() -> Result<Option<ProviderConfig>, String> { + let Some(path) = config_path() else { + return Ok(None); + }; + if !path.exists() { + return Ok(None); + } + + let contents = + std::fs::read_to_string(&path).map_err(|error| format!("read {path:?}: {error}"))?; + let parsed: ProvidersFile = + toml::from_str(&contents).map_err(|error| format!("parse {path:?}: {error}"))?; + + let Some(active) = parsed.active else { + return Ok(None); + }; + + let entry = parsed + .provider + .into_iter() + .find(|entry| entry.name == active) + .ok_or_else(|| format!("active profile {active:?} not found"))?; + + Ok(Some(ProviderConfig { + display_name: format!("{} ({})", entry.name, entry.model), + base_url: entry.base_url, + api_key: entry.api_key, + model: entry.model, + })) +} + +/// Treat an empty/whitespace string as absent. +fn non_empty(value: Option<String>) -> Option<String> { + value + .map(|string| string.trim().to_string()) + .filter(|string| !string.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_active_profile_from_toml() { + let toml = r#" + active = "onde-cloud" + + [[provider]] + name = "onde-cloud" + base_url = "https://cloud.ondeinference.com/v1" + api_key = "sk-test" + model = "onde-large" + + [[provider]] + name = "openai" + base_url = "https://api.openai.com/v1" + api_key = "sk-other" + model = "gpt-4o-mini" + "#; + let parsed: ProvidersFile = toml::from_str(toml).unwrap(); + let active = parsed.active.unwrap(); + let entry = parsed + .provider + .into_iter() + .find(|entry| entry.name == active) + .unwrap(); + assert_eq!(entry.base_url, "https://cloud.ondeinference.com/v1"); + assert_eq!(entry.model, "onde-large"); + } + + #[test] + fn non_empty_filters_blanks() { + assert_eq!(non_empty(Some(" ".to_string())), None); + assert_eq!(non_empty(Some(" x ".to_string())), Some("x".to_string())); + assert_eq!(non_empty(None), None); + } +}