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);
+ }
+}