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