@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
@@ -3647,7 +3647,7 @@ dependencies = [
3647 "tokio-tungstenite",
3648 "toktrie",
3649 "toktrie_hf_tokenizers",
3650 - "toml",
3650 + "toml 0.9.12+spec-1.1.0",
3651 "tracing",
3652 "tracing-subscriber",
3653 "urlencoding",
@@ -4586,6 +4586,27 @@ dependencies = [
4586 "windows-sys 0.52.0",
4587 ]
4588
4589 +[[package]]
4590 +name = "rpassword"
4591 +version = "7.5.4"
4592 +source = "registry+https://github.com/rust-lang/crates.io-index"
4593 +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
4594 +dependencies = [
4595 + "libc",
4596 + "rtoolbox",
4597 + "windows-sys 0.61.2",
4598 +]
4599 +
4600 +[[package]]
4601 +name = "rtoolbox"
4602 +version = "0.0.5"
4603 +source = "registry+https://github.com/rust-lang/crates.io-index"
4604 +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
4605 +dependencies = [
4606 + "libc",
4607 + "windows-sys 0.59.0",
4608 +]
4609 +
4610 [[package]]
4611 name = "rubato"
4612 version = "0.16.2"
@@ -5073,6 +5094,15 @@ dependencies = [
5094 "syn 2.0.117",
5095 ]
5096
5097 +[[package]]
5098 +name = "serde_spanned"
5099 +version = "0.6.9"
5100 +source = "registry+https://github.com/rust-lang/crates.io-index"
5101 +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
5102 +dependencies = [
5103 + "serde",
5104 +]
5105 +
5106 [[package]]
5107 name = "serde_spanned"
5108 version = "1.1.1"
@@ -5184,6 +5214,7 @@ version = "1.0.4"
5214 dependencies = [
5215 "agent-client-protocol",
5216 "anyhow",
5217 + "async-trait",
5218 "crossterm 0.29.0",
5219 "futures",
5220 "libc",
@@ -5192,9 +5223,12 @@ dependencies = [
5223 "ratatui",
5224 "regex",
5225 "reqwest 0.12.28",
5226 + "rpassword",
5227 + "serde",
5228 "serde_json",
5229 "tokio",
5230 "tokio-util",
5231 + "toml 0.8.23",
5232 "tracing-subscriber",
5233 "uuid 1.23.3",
5234 ]
@@ -6179,6 +6213,18 @@ dependencies = [
6213 "toktrie",
6214 ]
6215
6216 +[[package]]
6217 +name = "toml"
6218 +version = "0.8.23"
6219 +source = "registry+https://github.com/rust-lang/crates.io-index"
6220 +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
6221 +dependencies = [
6222 + "serde",
6223 + "serde_spanned 0.6.9",
6224 + "toml_datetime 0.6.11",
6225 + "toml_edit",
6226 +]
6227 +
6228 [[package]]
6229 name = "toml"
6230 version = "0.9.12+spec-1.1.0"
@@ -6187,13 +6233,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
6233 dependencies = [
6234 "indexmap 2.14.0",
6235 "serde_core",
6190 - "serde_spanned",
6191 - "toml_datetime",
6236 + "serde_spanned 1.1.1",
6237 + "toml_datetime 0.7.5+spec-1.1.0",
6238 "toml_parser",
6239 "toml_writer",
6240 "winnow 0.7.15",
6241 ]
6242
6243 +[[package]]
6244 +name = "toml_datetime"
6245 +version = "0.6.11"
6246 +source = "registry+https://github.com/rust-lang/crates.io-index"
6247 +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
6248 +dependencies = [
6249 + "serde",
6250 +]
6251 +
6252 [[package]]
6253 name = "toml_datetime"
6254 version = "0.7.5+spec-1.1.0"
@@ -6203,6 +6258,20 @@ dependencies = [
6258 "serde_core",
6259 ]
6260
6261 +[[package]]
6262 +name = "toml_edit"
6263 +version = "0.22.27"
6264 +source = "registry+https://github.com/rust-lang/crates.io-index"
6265 +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
6266 +dependencies = [
6267 + "indexmap 2.14.0",
6268 + "serde",
6269 + "serde_spanned 0.6.9",
6270 + "toml_datetime 0.6.11",
6271 + "toml_write",
6272 + "winnow 0.7.15",
6273 +]
6274 +
6275 [[package]]
6276 name = "toml_parser"
6277 version = "1.1.2+spec-1.1.0"
@@ -6212,6 +6281,12 @@ dependencies = [
6281 "winnow 1.0.3",
6282 ]
6283
6284 +[[package]]
6285 +name = "toml_write"
6286 +version = "0.1.2"
6287 +source = "registry+https://github.com/rust-lang/crates.io-index"
6288 +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
6289 +
6290 [[package]]
6291 name = "toml_writer"
6292 version = "1.1.1+spec-1.1.0"
@@ -6528,7 +6603,7 @@ dependencies = [
6603 "serde",
6604 "tempfile",
6605 "textwrap 0.16.2",
6531 - "toml",
6606 + "toml 0.9.12+spec-1.1.0",
6607 "uniffi_internal_macros",
6608 "uniffi_meta",
6609 "uniffi_pipeline",
@@ -6585,7 +6660,7 @@ dependencies = [
6660 "quote",
6661 "serde",
6662 "syn 2.0.117",
6588 - "toml",
6663 + "toml 0.9.12+spec-1.1.0",
6664 "uniffi_meta",
6665 ]
6666
Cargo.toml
+5 -1
@@ -38,7 +38,11 @@ anyhow = "1"
38 libc = "0.2"
39 log = "0.4"
40 tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
41 +serde = { version = "1", features = ["derive"] }
42 serde_json = "1"
43 +toml = "0.8"
44 +async-trait = "0.1"
45 +rpassword = "7"
46 regex = "1"
43 -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
47 +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
48 uuid = { version = "1", features = ["v4"] }
src/account.rs new
+160
@@ -0,0 +1,160 @@
1 +//! Account commands: `login`, `logout`, `whoami`.
2 +//!
3 +//! These authenticate against the siGit account API and store a session token
4 +//! locally. The token is the credential used for siGit Code Cloud requests.
5 +//!
6 +//! Base URL: `$SIGIT_API_URL`, else `https://sigit.si`.
7 +
8 +use serde::Deserialize;
9 +
10 +use crate::credentials::{self, Credentials};
11 +
12 +/// Default account API host. Override with `SIGIT_API_URL` (dev: `http://localhost:8088`).
13 +const DEFAULT_API_URL: &str = "https://sigit.si";
14 +
15 +fn api_base() -> String {
16 + std::env::var("SIGIT_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string())
17 +}
18 +
19 +// ── sigit.si /api/v1 response shapes ─────────────────────────────────────────────
20 +
21 +/// Sign-in response. A successful sign-in carries an `access_token`; an
22 +/// unverified account reports a `status`; failures arrive as an `error`.
23 +#[derive(Debug, Deserialize)]
24 +struct SignInResponse {
25 + #[serde(default)]
26 + access_token: Option<String>,
27 + #[serde(default)]
28 + status: Option<String>,
29 + #[serde(default)]
30 + error: Option<ApiError>,
31 +}
32 +
33 +#[derive(Debug, Deserialize)]
34 +struct ApiError {
35 + #[serde(default)]
36 + message: Option<String>,
37 +}
38 +
39 +#[derive(Debug, Deserialize)]
40 +struct MeResponse {
41 + #[serde(default)]
42 + email: Option<String>,
43 +}
44 +
45 +// ── Commands ──────────────────────────────────────────────────────────────────────
46 +
47 +/// `sigit login`: prompt for credentials, authenticate, and store the token.
48 +pub async fn login() -> anyhow::Result<()> {
49 + let base = api_base();
50 + println!("Sign in to siGit Code Cloud ({base})");
51 +
52 + let email = prompt("Email: ")?;
53 + let password = rpassword::prompt_password("Password: ")?;
54 + if email.trim().is_empty() || password.is_empty() {
55 + anyhow::bail!("email and password are required");
56 + }
57 +
58 + let url = format!("{}/api/v1/users/sign_in", base.trim_end_matches('/'));
59 + let response = reqwest::Client::new()
60 + .post(&url)
61 + .json(&serde_json::json!({ "email": email.trim(), "password": password }))
62 + .send()
63 + .await
64 + .map_err(|error| anyhow::anyhow!("could not reach siGit Code Cloud: {error}"))?;
65 +
66 + let status = response.status();
67 + let parsed: SignInResponse = response
68 + .json()
69 + .await
70 + .map_err(|error| anyhow::anyhow!("unexpected response from siGit Code Cloud: {error}"))?;
71 +
72 + if let Some(token) = parsed.access_token.filter(|token| !token.trim().is_empty()) {
73 + credentials::store(&Credentials {
74 + access_token: token,
75 + email: Some(email.trim().to_string()),
76 + })
77 + .map_err(|error| anyhow::anyhow!("could not save session: {error}"))?;
78 + println!("✓ Signed in as {}. siGit Code Cloud is ready.", email.trim());
79 + return Ok(());
80 + }
81 +
82 + // No token: surface the most specific message available.
83 + if let Some(message) = parsed.error.and_then(|error| error.message) {
84 + anyhow::bail!("sign-in failed: {message}");
85 + }
86 + if let Some(account_status) = parsed.status {
87 + anyhow::bail!(
88 + "sign-in incomplete (status: {account_status}). Check your email to verify your account."
89 + );
90 + }
91 + anyhow::bail!("sign-in failed (HTTP {})", status.as_u16());
92 +}
93 +
94 +/// `sigit logout`: clear the local session, notifying the server best-effort.
95 +pub async fn logout() -> anyhow::Result<()> {
96 + if let Some(token) = credentials::load_token() {
97 + let url = format!("{}/api/v1/users/sign_out", api_base().trim_end_matches('/'));
98 + // Best-effort: a failed server call must not block local logout.
99 + let _ = reqwest::Client::new()
100 + .delete(&url)
101 + .bearer_auth(&token)
102 + .send()
103 + .await;
104 + }
105 + if credentials::clear() {
106 + println!("✓ Signed out of siGit Code Cloud.");
107 + } else {
108 + println!("Not signed in.");
109 + }
110 + Ok(())
111 +}
112 +
113 +/// `sigit whoami`: show the signed-in account, verifying the token if reachable.
114 +pub async fn whoami() -> anyhow::Result<()> {
115 + let Some(creds) = credentials::load() else {
116 + println!("Not signed in. Run `sigit login` to use siGit Code Cloud.");
117 + return Ok(());
118 + };
119 +
120 + let url = format!("{}/api/v1/me", api_base().trim_end_matches('/'));
121 + match reqwest::Client::new()
122 + .get(&url)
123 + .bearer_auth(&creds.access_token)
124 + .send()
125 + .await
126 + {
127 + Ok(response) if response.status().is_success() => {
128 + let email = response
129 + .json::<MeResponse>()
130 + .await
131 + .ok()
132 + .and_then(|me| me.email)
133 + .or(creds.email)
134 + .unwrap_or_else(|| "(unknown)".to_string());
135 + println!("Signed in to siGit Code Cloud as {email}.");
136 + }
137 + Ok(response) => {
138 + println!(
139 + "Session may be expired (HTTP {}). Run `sigit login` again.",
140 + response.status().as_u16()
141 + );
142 + }
143 + Err(_) => {
144 + // Offline: fall back to the cached email.
145 + let email = creds.email.unwrap_or_else(|| "(unknown)".to_string());
146 + println!("Signed in as {email} (could not reach siGit Code Cloud to verify).");
147 + }
148 + }
149 + Ok(())
150 +}
151 +
152 +/// Print a prompt and read one trimmed line from stdin.
153 +fn prompt(label: &str) -> anyhow::Result<String> {
154 + use std::io::Write;
155 + print!("{label}");
156 + std::io::stdout().flush()?;
157 + let mut line = String::new();
158 + std::io::stdin().read_line(&mut line)?;
159 + Ok(line.trim_end_matches(['\n', '\r']).to_string())
160 +}
src/backend.rs new
+434
@@ -0,0 +1,434 @@
1 +//! Inference backend abstraction.
2 +//!
3 +//! The agent loop only needs to send a turn (optionally with tools) and return
4 +//! tool results. This module defines that seam as the `InferenceBackend` trait
5 +//! plus a few neutral types, with two implementations:
6 +//!
7 +//! - `LocalBackend` runs on-device through the `onde` crate (`ChatEngine`).
8 +//! - `OpenAiBackend` talks to any OpenAI-compatible HTTP endpoint, configured by
9 +//! `base_url`, `api_key`, and `model`.
10 +//!
11 +//! The trait exposes neither `onde` nor OpenAI types, so the loop does not depend
12 +//! on a specific backend.
13 +
14 +use std::sync::Arc;
15 +
16 +use async_trait::async_trait;
17 +use onde::inference::{ChatEngine, ToolDefinition};
18 +use serde::Deserialize;
19 +use tokio::sync::Mutex;
20 +
21 +// ── Neutral types ───────────────────────────────────────────────────────────────
22 +
23 +/// A tool the model may call, in a provider-neutral form. `parameters_schema` is
24 +/// a JSON Schema encoded as a string (matching how siGit already declares tools).
25 +#[derive(Debug, Clone)]
26 +pub struct ToolSpec {
27 + pub name: String,
28 + pub description: String,
29 + pub parameters_schema: String,
30 +}
31 +
32 +/// A tool call requested by the model.
33 +#[derive(Debug, Clone)]
34 +pub struct ToolCall {
35 + pub id: String,
36 + pub name: String,
37 + /// Arguments as a JSON-encoded string.
38 + pub arguments: String,
39 +}
40 +
41 +/// The output of executing one tool call, fed back to the model.
42 +#[derive(Debug, Clone)]
43 +pub struct ToolResult {
44 + pub tool_call_id: String,
45 + pub content: String,
46 +}
47 +
48 +/// The result of one assistant turn: free text and/or tool calls.
49 +#[derive(Debug, Clone, Default)]
50 +pub struct TurnResult {
51 + pub text: String,
52 + pub tool_calls: Vec<ToolCall>,
53 +}
54 +
55 +/// Backend errors are plain strings. Callers map them to ACP errors.
56 +pub type BackendError = String;
57 +
58 +// ── The trait ───────────────────────────────────────────────────────────────────
59 +
60 +/// A swappable inference backend driving siGit Code's agent loop.
61 +#[async_trait]
62 +pub trait InferenceBackend: Send + Sync {
63 + /// Start an assistant turn from a new user message, offering `tools`.
64 + async fn send_message_with_tools(
65 + &self,
66 + text: &str,
67 + tools: &[ToolSpec],
68 + ) -> Result<TurnResult, BackendError>;
69 +
70 + /// Continue the turn by returning tool results. `tools` may be `None` on the
71 + /// final round to force a text answer.
72 + async fn send_tool_results(
73 + &self,
74 + results: Vec<ToolResult>,
75 + tools: Option<&[ToolSpec]>,
76 + ) -> Result<TurnResult, BackendError>;
77 +
78 + /// Whether inference runs over the network (a configured provider) rather
79 + /// than on-device. Drives UI labelling so the displayed model can't claim a
80 + /// local model while requests actually go to the cloud.
81 + fn is_remote(&self) -> bool;
82 +}
83 +
84 +// ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
85 +
86 +/// On-device inference. A thin adapter over `onde::ChatEngine`.
87 +pub struct LocalBackend {
88 + engine: Arc<ChatEngine>,
89 +}
90 +
91 +impl LocalBackend {
92 + pub fn new(engine: Arc<ChatEngine>) -> Self {
93 + Self { engine }
94 + }
95 +}
96 +
97 +fn to_onde_tools(tools: &[ToolSpec]) -> Vec<ToolDefinition> {
98 + tools
99 + .iter()
100 + .map(|tool| ToolDefinition {
101 + name: tool.name.clone(),
102 + description: tool.description.clone(),
103 + parameters_schema: tool.parameters_schema.clone(),
104 + })
105 + .collect()
106 +}
107 +
108 +#[async_trait]
109 +impl InferenceBackend for LocalBackend {
110 + async fn send_message_with_tools(
111 + &self,
112 + text: &str,
113 + tools: &[ToolSpec],
114 + ) -> Result<TurnResult, BackendError> {
115 + let onde_tools = to_onde_tools(tools);
116 + let result = self
117 + .engine
118 + .send_message_with_tools(text, &onde_tools)
119 + .await
120 + .map_err(|error| error.to_string())?;
121 + Ok(onde_result_to_turn(result))
122 + }
123 +
124 + async fn send_tool_results(
125 + &self,
126 + results: Vec<ToolResult>,
127 + tools: Option<&[ToolSpec]>,
128 + ) -> Result<TurnResult, BackendError> {
129 + let onde_results: Vec<onde::inference::ToolResult> = results
130 + .into_iter()
131 + .map(|result| onde::inference::ToolResult {
132 + tool_call_id: result.tool_call_id,
133 + content: result.content,
134 + })
135 + .collect();
136 + let onde_tools = tools.map(to_onde_tools);
137 + let result = self
138 + .engine
139 + .send_tool_results(onde_results, onde_tools.as_deref())
140 + .await
141 + .map_err(|error| error.to_string())?;
142 + Ok(onde_result_to_turn(result))
143 + }
144 +
145 + fn is_remote(&self) -> bool {
146 + false
147 + }
148 +}
149 +
150 +/// Convert an `onde` tool-aware result into the neutral [`TurnResult`].
151 +fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
152 + TurnResult {
153 + text: result.text,
154 + tool_calls: result
155 + .tool_calls
156 + .into_iter()
157 + .map(|call| ToolCall {
158 + id: call.id,
159 + name: call.function_name,
160 + arguments: call.arguments,
161 + })
162 + .collect(),
163 + }
164 +}
165 +
166 +// ── OpenAI-compatible backend ─────────────────────────────────────────────────────
167 +
168 +/// Inference against any OpenAI-compatible Chat Completions endpoint.
169 +///
170 +/// Conversation state is held client-side and replayed on every request, so the
171 +/// endpoint can be stateless. Standard OpenAI function-calling is used end to
172 +/// end (`tools`, `choices[].message.tool_calls`, `role: "tool"` follow-ups).
173 +pub struct OpenAiBackend {
174 + base_url: String,
175 + api_key: String,
176 + model: String,
177 + http: reqwest::Client,
178 + /// The full message list sent on each request (system + turns + tool results).
179 + history: Mutex<Vec<serde_json::Value>>,
180 +}
181 +
182 +impl OpenAiBackend {
183 + /// Build a backend for `{base_url, api_key, model}`, seeding the optional
184 + /// system prompt. `base_url` should include the API root (e.g. ending in
185 + /// `/v1`); the chat path is appended.
186 + pub fn new(
187 + base_url: impl Into<String>,
188 + api_key: impl Into<String>,
189 + model: impl Into<String>,
190 + system_prompt: Option<String>,
191 + ) -> Self {
192 + let mut history = Vec::new();
193 + if let Some(prompt) = system_prompt {
194 + history.push(serde_json::json!({ "role": "system", "content": prompt }));
195 + }
196 + Self {
197 + base_url: base_url.into(),
198 + api_key: api_key.into(),
199 + model: model.into(),
200 + http: reqwest::Client::new(),
201 + history: Mutex::new(history),
202 + }
203 + }
204 +
205 + fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
206 + tools
207 + .iter()
208 + .map(|tool| {
209 + // parameters_schema is a JSON string; parse it, defaulting to an
210 + // empty object schema if malformed.
211 + let parameters: serde_json::Value = serde_json::from_str(&tool.parameters_schema)
212 + .unwrap_or_else(|_| serde_json::json!({ "type": "object", "properties": {} }));
213 + serde_json::json!({
214 + "type": "function",
215 + "function": {
216 + "name": tool.name,
217 + "description": tool.description,
218 + "parameters": parameters,
219 + }
220 + })
221 + })
222 + .collect()
223 + }
224 +
225 + /// POST the current history (plus `tools`) and apply the assistant reply to
226 + /// history, returning the neutral turn result.
227 + async fn complete(&self, tools: Option<&[ToolSpec]>) -> Result<TurnResult, BackendError> {
228 + let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
229 +
230 + let mut body = serde_json::json!({
231 + "model": self.model,
232 + "messages": *self.history.lock().await,
233 + "stream": false,
234 + });
235 + if let Some(tools) = tools
236 + && !tools.is_empty()
237 + {
238 + body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
239 + }
240 +
241 + let response = self
242 + .http
243 + .post(&url)
244 + .bearer_auth(&self.api_key)
245 + .json(&body)
246 + .send()
247 + .await
248 + .map_err(|error| format!("request to {url} failed: {error}"))?;
249 +
250 + if !response.status().is_success() {
251 + let status = response.status();
252 + let detail = response.text().await.unwrap_or_default();
253 + return Err(format!("endpoint returned {status}: {detail}"));
254 + }
255 +
256 + let parsed: ChatCompletion = response
257 + .json()
258 + .await
259 + .map_err(|error| format!("response parse error: {error}"))?;
260 +
261 + let message = parsed
262 + .choices
263 + .into_iter()
264 + .next()
265 + .map(|choice| choice.message)
266 + .ok_or_else(|| "endpoint returned no choices".to_string())?;
267 +
268 + let text = message.content.clone().unwrap_or_default();
269 + let tool_calls: Vec<ToolCall> = message
270 + .tool_calls
271 + .iter()
272 + .flatten()
273 + .map(|call| ToolCall {
274 + id: call.id.clone(),
275 + name: call.function.name.clone(),
276 + arguments: call.function.arguments.clone(),
277 + })
278 + .collect();
279 +
280 + // Record the assistant turn so later tool results have context.
281 + self.history.lock().await.push(message.into_history_value());
282 +
283 + Ok(TurnResult { text, tool_calls })
284 + }
285 +}
286 +
287 +#[async_trait]
288 +impl InferenceBackend for OpenAiBackend {
289 + async fn send_message_with_tools(
290 + &self,
291 + text: &str,
292 + tools: &[ToolSpec],
293 + ) -> Result<TurnResult, BackendError> {
294 + self.history
295 + .lock()
296 + .await
297 + .push(serde_json::json!({ "role": "user", "content": text }));
298 + self.complete(Some(tools)).await
299 + }
300 +
301 + async fn send_tool_results(
302 + &self,
303 + results: Vec<ToolResult>,
304 + tools: Option<&[ToolSpec]>,
305 + ) -> Result<TurnResult, BackendError> {
306 + {
307 + let mut history = self.history.lock().await;
308 + for result in results {
309 + history.push(serde_json::json!({
310 + "role": "tool",
311 + "tool_call_id": result.tool_call_id,
312 + "content": result.content,
313 + }));
314 + }
315 + }
316 + self.complete(tools).await
317 + }
318 +
319 + fn is_remote(&self) -> bool {
320 + true
321 + }
322 +}
323 +
324 +// ── OpenAI response shapes ────────────────────────────────────────────────────────
325 +
326 +#[derive(Debug, Deserialize)]
327 +struct ChatCompletion {
328 + #[serde(default)]
329 + choices: Vec<CompletionChoice>,
330 +}
331 +
332 +#[derive(Debug, Deserialize)]
333 +struct CompletionChoice {
334 + message: ResponseMessage,
335 +}
336 +
337 +#[derive(Debug, Deserialize)]
338 +struct ResponseMessage {
339 + #[serde(default)]
340 + content: Option<String>,
341 + #[serde(default)]
342 + tool_calls: Option<Vec<ResponseToolCall>>,
343 +}
344 +
345 +impl ResponseMessage {
346 + /// Reconstruct the assistant message for replay in history, preserving any
347 + /// tool calls so the follow-up request is well-formed.
348 + fn into_history_value(self) -> serde_json::Value {
349 + let mut message = serde_json::json!({ "role": "assistant" });
350 + message["content"] = match self.content {
351 + Some(text) => serde_json::Value::String(text),
352 + None => serde_json::Value::Null,
353 + };
354 + if let Some(tool_calls) = self.tool_calls {
355 + message["tool_calls"] = serde_json::json!(
356 + tool_calls
357 + .into_iter()
358 + .map(|call| serde_json::json!({
359 + "id": call.id,
360 + "type": "function",
361 + "function": {
362 + "name": call.function.name,
363 + "arguments": call.function.arguments,
364 + }
365 + }))
366 + .collect::<Vec<_>>()
367 + );
368 + }
369 + message
370 + }
371 +}
372 +
373 +#[derive(Debug, Deserialize)]
374 +struct ResponseToolCall {
375 + id: String,
376 + function: ResponseFunction,
377 +}
378 +
379 +#[derive(Debug, Deserialize)]
380 +struct ResponseFunction {
381 + name: String,
382 + #[serde(default)]
383 + arguments: String,
384 +}
385 +
386 +#[cfg(test)]
387 +mod tests {
388 + use super::*;
389 +
390 + #[test]
391 + fn tools_json_wraps_function_schema() {
392 + let tools = vec![ToolSpec {
393 + name: "read_file".to_string(),
394 + description: "Read a file".to_string(),
395 + parameters_schema: r#"{"type":"object","properties":{"path":{"type":"string"}}}"#
396 + .to_string(),
397 + }];
398 + let json = OpenAiBackend::tools_json(&tools);
399 + assert_eq!(json[0]["type"], "function");
400 + assert_eq!(json[0]["function"]["name"], "read_file");
401 + assert_eq!(json[0]["function"]["parameters"]["properties"]["path"]["type"], "string");
402 + }
403 +
404 + #[test]
405 + fn malformed_schema_falls_back_to_empty_object() {
406 + let tools = vec![ToolSpec {
407 + name: "x".to_string(),
408 + description: String::new(),
409 + parameters_schema: "not json".to_string(),
410 + }];
411 + let json = OpenAiBackend::tools_json(&tools);
412 + assert_eq!(json[0]["function"]["parameters"]["type"], "object");
413 + }
414 +
415 + #[test]
416 + fn assistant_message_with_tool_calls_round_trips() {
417 + let message = ResponseMessage {
418 + content: None,
419 + tool_calls: Some(vec![ResponseToolCall {
420 + id: "call_1".to_string(),
421 + function: ResponseFunction {
422 + name: "read_file".to_string(),
423 + arguments: r#"{"path":"a.rs"}"#.to_string(),
424 + },
425 + }]),
426 + };
427 + let value = message.into_history_value();
428 + assert_eq!(value["role"], "assistant");
429 + assert!(value["content"].is_null());
430 + assert_eq!(value["tool_calls"][0]["id"], "call_1");
431 + assert_eq!(value["tool_calls"][0]["type"], "function");
432 + assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
433 + }
434 +}
src/chat.rs
+32 -23
@@ -75,8 +75,9 @@ mod tui {
75 use anyhow::Result;
76 use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
77 use futures::StreamExt;
78 - use onde::inference::{ChatEngine, SamplingConfig, StreamChunk, ToolDefinition, ToolResult};
78 + use onde::inference::{ChatEngine, SamplingConfig, StreamChunk};
79
80 + use crate::backend::{InferenceBackend, ToolResult, ToolSpec};
81 use crate::models::{ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items};
82 use ratatui::{
83 Frame,
@@ -233,13 +234,22 @@ mod tui {
234 }
235
236 impl App {
236 - fn new(load_model_name: String) -> Self {
237 + fn new(load_model_name: String, is_remote: bool) -> Self {
238 let items = build_model_picker_items();
239 let tool_calling = items
240 .iter()
241 .find(|m| m.display_name == load_model_name)
242 .map(|m| m.tool_calling)
243 .unwrap_or(true);
244 + // For a remote provider the passed-in name is authoritative; the
245 + // persisted local selection must not override it (or the title would
246 + // show an on-device model while requests go to the cloud).
247 + let current_model_name = if is_remote {
248 + load_model_name.clone()
249 + } else {
250 + crate::setup::load_selected_model_name()
251 + .unwrap_or_else(|| load_model_name.clone())
252 + };
253 Self {
254 messages: Vec::new(),
255 input: String::new(),
@@ -267,8 +277,7 @@ mod tui {
277 show_model_picker: false,
278 model_picker_index: 0,
279 model_picker_items: items,
270 - current_model_name: crate::setup::load_selected_model_name()
271 - .unwrap_or_else(|| load_model_name.clone()),
280 + current_model_name,
281 tool_calling,
282 }
283 }
@@ -1250,10 +1259,10 @@ mod tui {
1259 /// cap tool rounds so a confused model can't loop forever
1260 const MAX_TOOL_ROUNDS: usize = 10;
1261
1253 - fn build_onde_tools() -> Vec<ToolDefinition> {
1262 + fn build_tool_specs() -> Vec<ToolSpec> {
1263 crate::tools::all_tools()
1264 .into_iter()
1256 - .map(|t| ToolDefinition {
1265 + .map(|t| ToolSpec {
1266 name: t.name.to_string(),
1267 description: t.description.to_string(),
1268 parameters_schema: t.parameters_schema.to_string(),
@@ -1264,21 +1273,21 @@ mod tui {
1273 /// run the tool-calling loop off the main thread, posting updates via `tx`.
1274 /// dropping `tx` signals completion to the event loop.
1275 async fn run_inference_task(
1267 - engine: Arc<ChatEngine>,
1276 + backend: Arc<dyn InferenceBackend>,
1277 text: String,
1278 tx: mpsc::Sender<InferenceUpdate>,
1279 tools_enabled: bool,
1280 ) {
1272 - let onde_tools = if tools_enabled {
1273 - build_onde_tools()
1281 + let tools = if tools_enabled {
1282 + build_tool_specs()
1283 } else {
1284 vec![]
1285 };
1286
1278 - let mut result = match engine.send_message_with_tools(&text, &onde_tools).await {
1287 + let mut result = match backend.send_message_with_tools(&text, &tools).await {
1288 Ok(r) => r,
1289 Err(err) => {
1281 - let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
1290 + let _ = tx.send(InferenceUpdate::Error(err)).await;
1291 return;
1292 }
1293 };
@@ -1294,15 +1303,13 @@ mod tui {
1303 for tc in &result.tool_calls {
1304 log::info!(
1305 " → {}({})",
1297 - tc.function_name,
1306 + tc.name,
1307 tc.arguments.chars().take(120).collect::<String>()
1308 );
1309
1301 - let _ = tx
1302 - .send(InferenceUpdate::ToolUse(tc.function_name.clone()))
1303 - .await;
1310 + let _ = tx.send(InferenceUpdate::ToolUse(tc.name.clone())).await;
1311
1305 - let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await;
1312 + let output = crate::tools::execute_tool(&tc.name, &tc.arguments).await;
1313 log::info!(" ← {} chars", output.len());
1314
1315 tool_results.push(ToolResult {
@@ -1313,15 +1320,15 @@ mod tui {
1320
1321 // on the last round, pass no tools so the model must produce text
1322 let next_tools = if round < MAX_TOOL_ROUNDS {
1316 - Some(onde_tools.as_slice())
1323 + Some(tools.as_slice())
1324 } else {
1325 None
1326 };
1327
1321 - match engine.send_tool_results(tool_results, next_tools).await {
1328 + match backend.send_tool_results(tool_results, next_tools).await {
1329 Ok(r) => result = r,
1330 Err(err) => {
1324 - let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
1331 + let _ = tx.send(InferenceUpdate::Error(err)).await;
1332 return;
1333 }
1334 }
@@ -1356,19 +1363,21 @@ mod tui {
1363 pub async fn run_with<B: ratatui::backend::Backend>(
1364 terminal: &mut ratatui::Terminal<B>,
1365 engine: Arc<ChatEngine>,
1366 + backend: Arc<dyn InferenceBackend>,
1367 load_rx: std_mpsc::Receiver<Result<(), String>>,
1368 load_model_name: String,
1369 ) -> Result<()> {
1362 - event_loop(terminal, engine, load_rx, load_model_name).await
1370 + event_loop(terminal, engine, backend, load_rx, load_model_name).await
1371 }
1372
1373 async fn event_loop<B: ratatui::backend::Backend>(
1374 terminal: &mut ratatui::Terminal<B>,
1375 engine: Arc<ChatEngine>,
1376 + backend: Arc<dyn InferenceBackend>,
1377 load_rx: std_mpsc::Receiver<Result<(), String>>,
1378 load_model_name: String,
1379 ) -> Result<()> {
1371 - let mut app = App::new(load_model_name);
1380 + let mut app = App::new(load_model_name, backend.is_remote());
1381 let mut event_stream = EventStream::new();
1382
1383 // 10 fps is plenty for spinners
@@ -1603,11 +1612,11 @@ mod tui {
1612 let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
1613 app.inference_rx = Some(rx);
1614
1606 - let engine_handle = Arc::clone(&engine);
1615 + let backend_handle = Arc::clone(&backend);
1616 let user_text = text.clone();
1617 let tools_enabled = app.tool_calling;
1618 tokio::spawn(async move {
1610 - run_inference_task(engine_handle, user_text, tx, tools_enabled).await;
1619 + run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
1620 });
1621 }
1622 }
src/credentials.rs new
+107
@@ -0,0 +1,107 @@
1 +//! Local credential store.
2 +//!
3 +//! Holds the session token from `sigit login`, used to authenticate siGit Code
4 +//! Cloud requests. Stored as TOML at `$SIGIT_CONFIG_DIR/credentials.toml` or
5 +//! `~/.config/sigit/credentials.toml`, with `0600` permissions on Unix.
6 +
7 +use std::path::PathBuf;
8 +
9 +use serde::{Deserialize, Serialize};
10 +
11 +/// The persisted session, written on login and cleared on logout.
12 +#[derive(Debug, Clone, Serialize, Deserialize)]
13 +pub struct Credentials {
14 + /// Bearer token issued by sigit.si.
15 + pub access_token: String,
16 + /// Account email, kept for `whoami` display.
17 + #[serde(default)]
18 + pub email: Option<String>,
19 +}
20 +
21 +/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
22 +fn config_dir() -> Option<PathBuf> {
23 + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
24 + return Some(PathBuf::from(dir));
25 + }
26 + let home = std::env::var("HOME").ok()?;
27 + Some(PathBuf::from(home).join(".config/sigit"))
28 +}
29 +
30 +fn credentials_path() -> Option<PathBuf> {
31 + config_dir().map(|dir| dir.join("credentials.toml"))
32 +}
33 +
34 +/// Load stored credentials, or `None` if not logged in.
35 +pub fn load() -> Option<Credentials> {
36 + let path = credentials_path()?;
37 + let contents = std::fs::read_to_string(&path).ok()?;
38 + match toml::from_str::<Credentials>(&contents) {
39 + Ok(credentials) if !credentials.access_token.trim().is_empty() => Some(credentials),
40 + _ => None,
41 + }
42 +}
43 +
44 +/// Convenience: the bearer token alone, if logged in.
45 +pub fn load_token() -> Option<String> {
46 + load().map(|credentials| credentials.access_token)
47 +}
48 +
49 +/// Persist credentials, creating the config dir and restricting permissions.
50 +pub fn store(credentials: &Credentials) -> Result<(), String> {
51 + let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?;
52 + std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?;
53 + let path = dir.join("credentials.toml");
54 + let body =
55 + toml::to_string(credentials).map_err(|error| format!("serialize credentials: {error}"))?;
56 + std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?;
57 + restrict_permissions(&path);
58 + Ok(())
59 +}
60 +
61 +/// Remove stored credentials. Returns `true` if a file was deleted.
62 +pub fn clear() -> bool {
63 + match credentials_path() {
64 + Some(path) if path.exists() => std::fs::remove_file(&path).is_ok(),
65 + _ => false,
66 + }
67 +}
68 +
69 +#[cfg(unix)]
70 +fn restrict_permissions(path: &std::path::Path) {
71 + use std::os::unix::fs::PermissionsExt;
72 + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
73 +}
74 +
75 +#[cfg(not(unix))]
76 +fn restrict_permissions(_path: &std::path::Path) {}
77 +
78 +#[cfg(test)]
79 +mod tests {
80 + use super::*;
81 +
82 + #[test]
83 + fn round_trips_credentials_via_temp_dir() {
84 + let dir = std::env::temp_dir().join(format!("sigit_creds_test_{}", std::process::id()));
85 + let _ = std::fs::remove_dir_all(&dir);
86 + // SAFETY: single-threaded test; restores below.
87 + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
88 +
89 + assert!(load().is_none());
90 + store(&Credentials {
91 + access_token: "tok_123".to_string(),
92 + email: Some("dev@sigit.si".to_string()),
93 + })
94 + .unwrap();
95 +
96 + let loaded = load().expect("credentials present");
97 + assert_eq!(loaded.access_token, "tok_123");
98 + assert_eq!(loaded.email.as_deref(), Some("dev@sigit.si"));
99 + assert_eq!(load_token().as_deref(), Some("tok_123"));
100 +
101 + assert!(clear());
102 + assert!(load().is_none());
103 +
104 + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
105 + let _ = std::fs::remove_dir_all(&dir);
106 + }
107 +}
src/main.rs
+66 -11
@@ -28,8 +28,12 @@
28 //! }
29 //! ```
30
31 +mod account;
32 +mod backend;
33 mod chat;
34 +mod credentials;
35 mod models;
36 +mod provider;
37 mod setup;
38 mod tools;
39
@@ -53,6 +57,8 @@ use agent_client_protocol::schema::{
57 };
58 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
59 use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
60 +
61 +use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend};
62 use std::path::PathBuf;
63 use std::sync::atomic::{AtomicBool, Ordering};
64 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
@@ -1834,28 +1840,62 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1840 // std::sync::mpsc on a real thread so model loading can't starve the TUI
1841 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
1842
1837 - let loader_engine = Arc::clone(&engine);
1843 let tool_calling = models::build_model_picker_items()
1844 .iter()
1845 .find(|item| item.config.model_id == config.model_id)
1846 .map(|item| item.tool_calling)
1847 .unwrap_or(false);
1843 - let system_prompt = system_prompt_for_model(tool_calling).to_string();
1844 - std::thread::spawn(move || {
1845 - let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1846 - let result =
1847 - rt.block_on(loader_engine.load_gguf_model(config, Some(system_prompt), Some(sampling)));
1848 - let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
1849 - });
1848 +
1849 + // Pick the inference backend: a configured provider if present, else on-device.
1850 + let (inference_backend, startup_model_name): (Arc<dyn InferenceBackend>, String) =
1851 + match provider::active_provider() {
1852 + Some(provider) => {
1853 + log::info!(
1854 + "inference: using {} (model {}) at {}",
1855 + provider.display_name,
1856 + provider.model,
1857 + provider.base_url
1858 + );
1859 + // No local model to load; the endpoint is ready immediately.
1860 + let _ = load_tx.send(Ok(()));
1861 + let label = provider.display_name.clone();
1862 + let backend = Arc::new(OpenAiBackend::new(
1863 + provider.base_url,
1864 + provider.api_key,
1865 + provider.model,
1866 + Some(SYSTEM_PROMPT.to_string()),
1867 + )) as Arc<dyn InferenceBackend>;
1868 + (backend, label)
1869 + }
1870 + None => {
1871 + // On-device: load the local GGUF model on a real thread.
1872 + let loader_engine = Arc::clone(&engine);
1873 + let system_prompt = system_prompt_for_model(tool_calling).to_string();
1874 + std::thread::spawn(move || {
1875 + let rt =
1876 + tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1877 + let result = rt.block_on(loader_engine.load_gguf_model(
1878 + config,
1879 + Some(system_prompt),
1880 + Some(sampling),
1881 + ));
1882 + let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
1883 + });
1884 + let backend =
1885 + Arc::new(LocalBackend::new(Arc::clone(&engine))) as Arc<dyn InferenceBackend>;
1886 + (backend, startup_model_name)
1887 + }
1888 + };
1889
1890 crossterm::terminal::enable_raw_mode()?;
1891 let mut tty = BufWriter::new(tty);
1892 crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
1854 - let backend = ratatui::backend::CrosstermBackend::new(tty);
1855 - let mut terminal = ratatui::Terminal::new(backend)?;
1893 + let term_backend = ratatui::backend::CrosstermBackend::new(tty);
1894 + let mut terminal = ratatui::Terminal::new(term_backend)?;
1895
1896 // polls load_rx with try_recv() each tick, no blocking
1858 - let chat_result = chat::run_with(&mut terminal, engine, load_rx, startup_model_name).await;
1897 + let chat_result =
1898 + chat::run_with(&mut terminal, engine, inference_backend, load_rx, startup_model_name).await;
1899
1900 // cleanup fd because backend's writer is private
1901 crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?;
@@ -2029,6 +2069,21 @@ async fn run_acp_server() -> anyhow::Result<()> {
2069
2070 #[tokio::main]
2071 async fn main() -> anyhow::Result<()> {
2072 + // Account subcommands run before the TUI/ACP split. They are plain CLI verbs.
2073 + if let Some(command) = std::env::args().nth(1) {
2074 + match command.as_str() {
2075 + "login" | "logout" | "whoami" => {
2076 + init_logging(false);
2077 + return match command.as_str() {
2078 + "login" => account::login().await,
2079 + "logout" => account::logout().await,
2080 + _ => account::whoami().await,
2081 + };
2082 + }
2083 + _ => {}
2084 + }
2085 + }
2086 +
2087 let is_tty = std::io::stdin().is_terminal();
2088
2089 if is_tty {
src/provider.rs new
+218
@@ -0,0 +1,218 @@
1 +//! Inference provider configuration.
2 +//!
3 +//! Decides which backend serves inference. Resolution order, first match wins:
4 +//!
5 +//! 1. Override: `OPENAI_BASE_URL` + `OPENAI_API_KEY`, or the active profile in
6 +//! `~/.config/sigit/providers.toml`.
7 +//! 2. siGit Code Cloud: used when the user is logged in (`sigit login`). The
8 +//! endpoint and tier are built in, and the session token is the credential.
9 +//! 3. On-device: no login and no override, so inference runs locally.
10 +
11 +use std::path::PathBuf;
12 +
13 +use serde::Deserialize;
14 +
15 +/// Default siGit Code Cloud inference endpoint. Override with `SIGIT_CLOUD_URL`
16 +/// (dev: `http://localhost:8090/v1`).
17 +const DEFAULT_CLOUD_URL: &str = "https://cloud.ondeinference.com/v1";
18 +
19 +/// Default quality tier when the user hasn't chosen one. Override with `SIGIT_TIER`
20 +/// (`fast` | `balanced` | `large`).
21 +const DEFAULT_TIER: &str = "balanced";
22 +
23 +/// Map a neutral tier name to the model id sent on the wire. Unknown values pass
24 +/// through unchanged so an explicit model id still works.
25 +fn tier_to_model(tier: &str) -> String {
26 + match tier.trim().to_lowercase().as_str() {
27 + "fast" => "onde-fast",
28 + "balanced" => "onde-balanced",
29 + "large" => "onde-large",
30 + other => other,
31 + }
32 + .to_string()
33 +}
34 +
35 +/// A resolved inference provider: everything needed to build an OpenAI-compatible
36 +/// client. Deliberately free of any Onde/smbCloud-specific fields.
37 +#[derive(Debug, Clone)]
38 +pub struct ProviderConfig {
39 + /// Human-facing name shown in the UI (e.g. `siGit Code Cloud · Balanced`).
40 + pub display_name: String,
41 + /// API root, e.g. `https://cloud.ondeinference.com/v1`.
42 + pub base_url: String,
43 + pub api_key: String,
44 + /// Model id sent to the endpoint, e.g. `onde-balanced` or `gpt-4o-mini`.
45 + pub model: String,
46 +}
47 +
48 +/// Title-case a tier name for display (`balanced` → `Balanced`).
49 +fn tier_title(tier: &str) -> String {
50 + let tier = tier.trim();
51 + let mut chars = tier.chars();
52 + match chars.next() {
53 + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
54 + None => "Balanced".to_string(),
55 + }
56 +}
57 +
58 +/// Default model id used when an environment-configured provider omits one.
59 +const DEFAULT_ENV_MODEL: &str = "onde-large";
60 +
61 +/// Resolve the active provider, or `None` to run on-device.
62 +pub fn active_provider() -> Option<ProviderConfig> {
63 + // 1. Explicit override (env or providers.toml).
64 + if let Some(config) = from_env() {
65 + return Some(config);
66 + }
67 + match from_config_file() {
68 + Ok(Some(config)) => return Some(config),
69 + Ok(None) => {}
70 + Err(error) => log::warn!("provider: ignoring providers.toml: {error}"),
71 + }
72 + // 2. siGit Code Cloud, used when logged in.
73 + from_login()
74 + // 3. Otherwise None, meaning on-device.
75 +}
76 +
77 +/// The siGit Code Cloud provider, used once the user has logged in. The session
78 +/// token is the credential.
79 +fn from_login() -> Option<ProviderConfig> {
80 + let token = crate::credentials::load_token()?;
81 + let base_url =
82 + std::env::var("SIGIT_CLOUD_URL").unwrap_or_else(|_| DEFAULT_CLOUD_URL.to_string());
83 + let tier = std::env::var("SIGIT_TIER").unwrap_or_else(|_| DEFAULT_TIER.to_string());
84 + Some(ProviderConfig {
85 + display_name: format!("siGit Code Cloud · {}", tier_title(&tier)),
86 + base_url,
87 + api_key: token,
88 + model: tier_to_model(&tier),
89 + })
90 +}
91 +
92 +/// Provider from environment variables, if both URL and key are present.
93 +fn from_env() -> Option<ProviderConfig> {
94 + let base_url = non_empty(std::env::var("OPENAI_BASE_URL").ok())?;
95 + // A base URL with no key is almost always a mistake. Warn instead of
96 + // silently falling back to on-device, which looks like the cloud failed.
97 + let Some(api_key) = non_empty(std::env::var("OPENAI_API_KEY").ok()) else {
98 + log::warn!(
99 + "provider: OPENAI_BASE_URL is set but OPENAI_API_KEY is empty/missing; \
100 + staying on-device. Set OPENAI_API_KEY to use the remote provider."
101 + );
102 + return None;
103 + };
104 + let model = non_empty(std::env::var("SIGIT_MODEL").ok())
105 + .unwrap_or_else(|| DEFAULT_ENV_MODEL.to_string());
106 + Some(ProviderConfig {
107 + display_name: format!("{model} (custom endpoint)"),
108 + base_url,
109 + api_key,
110 + model,
111 + })
112 +}
113 +
114 +// ── providers.toml ────────────────────────────────────────────────────────────────
115 +
116 +#[derive(Debug, Deserialize)]
117 +struct ProvidersFile {
118 + /// Name of the profile to use.
119 + active: Option<String>,
120 + #[serde(default)]
121 + provider: Vec<ProviderEntry>,
122 +}
123 +
124 +#[derive(Debug, Deserialize)]
125 +struct ProviderEntry {
126 + name: String,
127 + base_url: String,
128 + api_key: String,
129 + model: String,
130 +}
131 +
132 +/// Path to the providers file: `$SIGIT_CONFIG_DIR` or `~/.config/sigit/providers.toml`.
133 +fn config_path() -> Option<PathBuf> {
134 + if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
135 + return Some(PathBuf::from(dir).join("providers.toml"));
136 + }
137 + let home = std::env::var("HOME").ok()?;
138 + Some(PathBuf::from(home).join(".config/sigit/providers.toml"))
139 +}
140 +
141 +/// Load the active profile from `providers.toml`, if the file exists and names one.
142 +fn from_config_file() -> Result<Option<ProviderConfig>, String> {
143 + let Some(path) = config_path() else {
144 + return Ok(None);
145 + };
146 + if !path.exists() {
147 + return Ok(None);
148 + }
149 +
150 + let contents =
151 + std::fs::read_to_string(&path).map_err(|error| format!("read {path:?}: {error}"))?;
152 + let parsed: ProvidersFile =
153 + toml::from_str(&contents).map_err(|error| format!("parse {path:?}: {error}"))?;
154 +
155 + let Some(active) = parsed.active else {
156 + return Ok(None);
157 + };
158 +
159 + let entry = parsed
160 + .provider
161 + .into_iter()
162 + .find(|entry| entry.name == active)
163 + .ok_or_else(|| format!("active profile {active:?} not found"))?;
164 +
165 + Ok(Some(ProviderConfig {
166 + display_name: format!("{} ({})", entry.name, entry.model),
167 + base_url: entry.base_url,
168 + api_key: entry.api_key,
169 + model: entry.model,
170 + }))
171 +}
172 +
173 +/// Treat an empty/whitespace string as absent.
174 +fn non_empty(value: Option<String>) -> Option<String> {
175 + value
176 + .map(|string| string.trim().to_string())
177 + .filter(|string| !string.is_empty())
178 +}
179 +
180 +#[cfg(test)]
181 +mod tests {
182 + use super::*;
183 +
184 + #[test]
185 + fn parses_active_profile_from_toml() {
186 + let toml = r#"
187 + active = "onde-cloud"
188 +
189 + [[provider]]
190 + name = "onde-cloud"
191 + base_url = "https://cloud.ondeinference.com/v1"
192 + api_key = "sk-test"
193 + model = "onde-large"
194 +
195 + [[provider]]
196 + name = "openai"
197 + base_url = "https://api.openai.com/v1"
198 + api_key = "sk-other"
199 + model = "gpt-4o-mini"
200 + "#;
201 + let parsed: ProvidersFile = toml::from_str(toml).unwrap();
202 + let active = parsed.active.unwrap();
203 + let entry = parsed
204 + .provider
205 + .into_iter()
206 + .find(|entry| entry.name == active)
207 + .unwrap();
208 + assert_eq!(entry.base_url, "https://cloud.ondeinference.com/v1");
209 + assert_eq!(entry.model, "onde-large");
210 + }
211 +
212 + #[test]
213 + fn non_empty_filters_blanks() {
214 + assert_eq!(non_empty(Some(" ".to_string())), None);
215 + assert_eq!(non_empty(Some(" x ".to_string())), Some("x".to_string()));
216 + assert_eq!(non_empty(None), None);
217 + }
218 +}