I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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 //! Provider resolution runs at startup in both modes: the interactive client
12 //! picks its whole backend from it, and `run_acp_server` applies the explicit
13 //! override (env or `providers.toml`) the same way. Some items are still wired
14 //! up only through `#[cfg(unix)]` interactive paths, so the dead-code lint
15 //! stays suppressed on non-Unix targets only — Unix builds keep full coverage.
16 #![cfg_attr(not(unix), allow(dead_code))]
17
18 use std::path::PathBuf;
19
20 use serde::Deserialize;
21
22 /// Default siGit Code Cloud inference endpoint. This is the siGit platform API
23 /// (sigit.si), which authenticates the user and forwards to the inference
24 /// backend; the client never talks to the backend directly. Override with
25 /// `SIGIT_CLOUD_URL` (dev: `http://localhost:8088/api/v1`).
26 const DEFAULT_CLOUD_URL: &str = "https://sigit.si/api/v1";
27
28 /// The cloud quality tiers, in display order (each line ordered smallest to
29 /// largest). Always offered in `/models`; selecting one requires a signed-in
30 /// account.
31 ///
32 /// The tiers group into several independent lines, and a tier is only routable
33 /// when the cloud has that line's family configured. Which family is which, and
34 /// what runs behind any of them, is deliberately not represented here: the
35 /// client knows tier names and nothing else.
36 ///
37 /// This list mirrors onde-cloud's router catalogue (`src/router.rs`) and
38 /// sigit-si's `CloudCatalog`. A tier the cloud serves but this list omits is
39 /// simply invisible in `/models` and the editor picker, so all three sides have
40 /// to be updated together when a tier is added.
41 pub const CLOUD_TIERS: &[&str] = &[
42 "fast", "balanced", "large", "mini", "air", "pro", "oke", "flux", "apex", "aura", "orbit",
43 "nova", "flare", "zenith", "prism", "lumen",
44 ];
45
46 pub const DEFAULT_CLOUD_TIER: &str = "nova";
47
48 /// Cloud tiers that read images attached to a prompt.
49 ///
50 /// This mirrors onde-cloud's `IMAGE_CANDIDATES` table, which is where the
51 /// per-provider facts live and where a request is actually accepted or
52 /// refused. A tier is listed here when at least one of its providers takes
53 /// images. The rest are text-only at the source: `nova` and `orbit` (GLM-5.3
54 /// and GLM-5.2), `apex` and `flux` (DeepSeek V4 Pro and V4 Flash).
55 ///
56 /// The list can be hard-coded because the tier list itself is: a tier this
57 /// client does not know is not offered at all (see [`CLOUD_TIERS`]).
58 const IMAGE_TIERS: &[&str] = &[
59 "fast", "balanced", "large", "mini", "air", "pro", "oke", "aura", "flare", "zenith", "prism",
60 "lumen",
61 ];
62
63 /// Whether the cloud tier `tier` reads images.
64 pub fn tier_accepts_images(tier: &str) -> bool {
65 IMAGE_TIERS.contains(&tier.trim().to_lowercase().as_str())
66 }
67
68 /// Whether the model id sent on the wire reads images.
69 ///
70 /// An `onde-*` id is one of our tiers and is answered from [`IMAGE_TIERS`]. Any
71 /// other id belongs to an endpoint the user configured themselves
72 /// (`OPENAI_BASE_URL` or a provider profile). Nothing is known about those, so
73 /// the image is passed through and the endpoint gets to decide.
74 pub fn model_accepts_images(model: &str) -> bool {
75 if !model.starts_with("onde-") {
76 return true;
77 }
78 CLOUD_TIERS
79 .iter()
80 .any(|tier| tier_to_model(tier) == model && tier_accepts_images(tier))
81 }
82
83 /// Whether the model id sent on the wire takes audio.
84 ///
85 /// No cloud tier does: onde-cloud drops `input_audio` parts when it parses a
86 /// request, so a clip sent to an `onde-*` id would vanish without a word. Any
87 /// other id is the user's own endpoint and, as with images, gets the audio and
88 /// decides for itself.
89 pub fn model_accepts_audio(model: &str) -> bool {
90 !model.starts_with("onde-")
91 }
92
93 /// Base URL of the siGit Code Cloud inference endpoint. Override with
94 /// `SIGIT_CLOUD_URL` (dev: `http://localhost:8090/v1`).
95 pub fn cloud_base_url() -> String {
96 std::env::var("SIGIT_CLOUD_URL").unwrap_or_else(|_| DEFAULT_CLOUD_URL.to_string())
97 }
98
99 /// Display label for a tier, e.g. `siGit Code Cloud · Balanced`.
100 pub fn cloud_tier_label(tier: &str) -> String {
101 format!("siGit Code Cloud · {}", tier_title(tier))
102 }
103
104 /// Build a siGit Code Cloud provider for `tier`, if the user is signed in.
105 /// Returns `None` when there is no stored session, so the caller can prompt for
106 /// login rather than silently failing.
107 pub fn cloud_tier_provider(tier: &str) -> Option<ProviderConfig> {
108 let token = crate::credentials::load_token()?;
109 Some(ProviderConfig {
110 display_name: cloud_tier_label(tier),
111 base_url: cloud_base_url(),
112 api_key: token,
113 model: tier_to_model(tier),
114 session_token: true,
115 })
116 }
117
118 /// The cloud tier a name refers to: the tier itself (`prism`) or the model id
119 /// sent on the wire (`onde-prism`), case-insensitively. `None` for a name that
120 /// is neither, so a caller can reject it instead of sending it to the cloud.
121 pub fn cloud_tier_named(name: &str) -> Option<&'static str> {
122 let name = name.trim().to_lowercase();
123 CLOUD_TIERS
124 .iter()
125 .copied()
126 .find(|tier| *tier == name || tier_to_model(tier) == name)
127 }
128
129 /// Map a neutral tier name to the model id sent on the wire. Unknown values pass
130 /// through unchanged so an explicit model id still works.
131 fn tier_to_model(tier: &str) -> String {
132 match tier.trim().to_lowercase().as_str() {
133 "fast" => "onde-fast",
134 "balanced" => "onde-balanced",
135 "large" => "onde-large",
136 "mini" => "onde-mini",
137 "air" => "onde-air",
138 "pro" => "onde-pro",
139 "oke" => "onde-kkk",
140 "flux" => "onde-flux",
141 "apex" => "onde-apex",
142 "aura" => "onde-aura",
143 "orbit" => "onde-orbit",
144 "nova" => "onde-nova",
145 "flare" => "onde-flare",
146 "zenith" => "onde-zenith",
147 "prism" => "onde-prism",
148 "lumen" => "onde-lumen",
149 other => other,
150 }
151 .to_string()
152 }
153
154 /// A resolved inference provider: everything needed to build an OpenAI-compatible
155 /// client. Deliberately free of any Onde/smbCloud-specific fields.
156 #[derive(Debug, Clone)]
157 pub struct ProviderConfig {
158 /// Human-facing name shown in the UI (e.g. `siGit Code Cloud · Balanced`).
159 pub display_name: String,
160 /// API root, e.g. `https://api.openai.com/v1`.
161 pub base_url: String,
162 pub api_key: String,
163 /// Model id sent to the endpoint, e.g. `onde-balanced` or `gpt-4o-mini`.
164 pub model: String,
165 /// Whether `api_key` is the stored siGit Code Cloud session token. A
166 /// backend built from such a config reads the token again on every
167 /// request, so signing in as another account takes effect without a
168 /// restart.
169 pub session_token: bool,
170 }
171
172 /// Title-case a tier name for display (`balanced` → `Balanced`).
173 pub fn tier_title(tier: &str) -> String {
174 let tier = tier.trim();
175 let mut chars = tier.chars();
176 match chars.next() {
177 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
178 None => "Balanced".to_string(),
179 }
180 }
181
182 /// Default model id used when an environment-configured provider omits one.
183 const DEFAULT_ENV_MODEL: &str = "onde-large";
184
185 /// Resolve the startup provider, or `None` to run on-device.
186 ///
187 /// This is only the explicit override (env or `providers.toml`), for power users
188 /// and BYO endpoints. Being signed in does **not** auto-select the cloud: model
189 /// choice is separate from identity, and the user picks a model or tier in
190 /// `/models`. With no override, siGit Code is local-first.
191 pub fn active_provider() -> Option<ProviderConfig> {
192 if let Some(config) = from_env() {
193 return Some(config);
194 }
195 match from_config_file() {
196 Ok(config) => config,
197 Err(error) => {
198 log::warn!("provider: ignoring providers.toml: {error}");
199 None
200 }
201 }
202 }
203
204 /// Provider from environment variables, if both URL and key are present.
205 fn from_env() -> Option<ProviderConfig> {
206 let base_url = non_empty(std::env::var("OPENAI_BASE_URL").ok())?;
207 // A base URL with no key is almost always a mistake. Warn instead of
208 // silently falling back to on-device, which looks like the cloud failed.
209 let Some(api_key) = non_empty(std::env::var("OPENAI_API_KEY").ok()) else {
210 log::warn!(
211 "provider: OPENAI_BASE_URL is set but OPENAI_API_KEY is empty/missing; \
212 staying on-device. Set OPENAI_API_KEY to use the remote provider."
213 );
214 return None;
215 };
216 let model = non_empty(std::env::var("SIGIT_MODEL").ok())
217 .unwrap_or_else(|| DEFAULT_ENV_MODEL.to_string());
218 Some(ProviderConfig {
219 display_name: format!("{model} (custom endpoint)"),
220 base_url,
221 api_key,
222 model,
223 session_token: false,
224 })
225 }
226
227 // ── providers.toml ────────────────────────────────────────────────────────────────
228
229 #[derive(Debug, Deserialize)]
230 struct ProvidersFile {
231 /// Name of the profile to use.
232 active: Option<String>,
233 #[serde(default)]
234 provider: Vec<ProviderEntry>,
235 }
236
237 #[derive(Debug, Deserialize)]
238 struct ProviderEntry {
239 name: String,
240 base_url: String,
241 api_key: String,
242 model: String,
243 }
244
245 /// Path to the providers file: `$SIGIT_CONFIG_DIR` or `~/.config/sigit/providers.toml`.
246 fn config_path() -> Option<PathBuf> {
247 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
248 return Some(PathBuf::from(dir).join("providers.toml"));
249 }
250 let home = std::env::var("HOME").ok()?;
251 Some(PathBuf::from(home).join(".config/sigit/providers.toml"))
252 }
253
254 /// Load the active profile from `providers.toml`, if the file exists and names one.
255 fn from_config_file() -> Result<Option<ProviderConfig>, String> {
256 let Some(path) = config_path() else {
257 return Ok(None);
258 };
259 if !path.exists() {
260 return Ok(None);
261 }
262
263 let contents =
264 std::fs::read_to_string(&path).map_err(|error| format!("read {path:?}: {error}"))?;
265 let parsed: ProvidersFile =
266 toml::from_str(&contents).map_err(|error| format!("parse {path:?}: {error}"))?;
267
268 let Some(active) = parsed.active else {
269 return Ok(None);
270 };
271
272 let entry = parsed
273 .provider
274 .into_iter()
275 .find(|entry| entry.name == active)
276 .ok_or_else(|| format!("active profile {active:?} not found"))?;
277
278 Ok(Some(ProviderConfig {
279 display_name: format!("{} ({})", entry.name, entry.model),
280 base_url: entry.base_url,
281 api_key: entry.api_key,
282 model: entry.model,
283 session_token: false,
284 }))
285 }
286
287 /// Treat an empty/whitespace string as absent.
288 fn non_empty(value: Option<String>) -> Option<String> {
289 value
290 .map(|string| string.trim().to_string())
291 .filter(|string| !string.is_empty())
292 }
293
294 #[cfg(test)]
295 mod tests {
296 use super::*;
297
298 #[test]
299 fn parses_active_profile_from_toml() {
300 let toml = r#"
301 active = "primary"
302
303 [[provider]]
304 name = "primary"
305 base_url = "https://api.example.com/v1"
306 api_key = "sk-test"
307 model = "model-a"
308
309 [[provider]]
310 name = "openai"
311 base_url = "https://api.openai.com/v1"
312 api_key = "sk-other"
313 model = "gpt-4o-mini"
314 "#;
315 let parsed: ProvidersFile = toml::from_str(toml).unwrap();
316 let active = parsed.active.unwrap();
317 let entry = parsed
318 .provider
319 .into_iter()
320 .find(|entry| entry.name == active)
321 .unwrap();
322 assert_eq!(entry.base_url, "https://api.example.com/v1");
323 assert_eq!(entry.model, "model-a");
324 }
325
326 #[test]
327 fn non_empty_filters_blanks() {
328 assert_eq!(non_empty(Some(" ".to_string())), None);
329 assert_eq!(non_empty(Some(" x ".to_string())), Some("x".to_string()));
330 assert_eq!(non_empty(None), None);
331 }
332
333 #[test]
334 fn maps_default_cloud_tier_to_wire_model_id() {
335 assert_eq!(DEFAULT_CLOUD_TIER, "nova");
336 assert_eq!(tier_to_model(DEFAULT_CLOUD_TIER), "onde-nova");
337 }
338
339 #[test]
340 fn cloud_tier_named_accepts_tiers_and_wire_ids() {
341 assert_eq!(cloud_tier_named("prism"), Some("prism"));
342 assert_eq!(cloud_tier_named(" Zenith "), Some("zenith"));
343 assert_eq!(cloud_tier_named("onde-flare"), Some("flare"));
344 // The wire id of `oke` does not follow the `onde-<tier>` pattern.
345 assert_eq!(cloud_tier_named("onde-kkk"), Some("oke"));
346 assert_eq!(cloud_tier_named("oke"), Some("oke"));
347 assert_eq!(cloud_tier_named("gpt-4o-mini"), None);
348 assert_eq!(cloud_tier_named("onde-"), None);
349 assert_eq!(cloud_tier_named(""), None);
350 }
351
352 #[test]
353 fn maps_every_cloud_tier_to_its_own_onde_model_id() {
354 for tier in CLOUD_TIERS {
355 // `oke` is the one tier whose wire id is not its own name.
356 let expected = match *tier {
357 "oke" => "onde-kkk".to_string(),
358 name => format!("onde-{name}"),
359 };
360 assert_eq!(tier_to_model(tier), expected);
361 }
362 }
363
364 #[test]
365 fn image_support_follows_the_tier_table() {
366 assert!(tier_accepts_images("large"));
367 assert!(tier_accepts_images("oke"));
368 // The default tier is text-only at the source.
369 assert!(!tier_accepts_images(DEFAULT_CLOUD_TIER));
370 assert!(!tier_accepts_images("apex"));
371
372 assert!(model_accepts_images("onde-large"));
373 // `oke` is the one tier whose wire id is not `onde-<tier>`.
374 assert!(model_accepts_images("onde-kkk"));
375 assert!(!model_accepts_images("onde-nova"));
376 // An onde id this client has never heard of is not assumed to work.
377 assert!(!model_accepts_images("onde-something-new"));
378 // A model on the user's own endpoint is theirs to judge.
379 assert!(model_accepts_images("gpt-4o-mini"));
380
381 for tier in IMAGE_TIERS {
382 assert!(CLOUD_TIERS.contains(tier), "{tier} is not a cloud tier");
383 }
384 }
385
386 #[test]
387 fn audio_goes_only_to_the_users_own_endpoint() {
388 for tier in CLOUD_TIERS {
389 assert!(!model_accepts_audio(&tier_to_model(tier)), "{tier}");
390 }
391 assert!(model_accepts_audio("gpt-4o-audio-preview"));
392 }
393 }