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",
44 ];
45
46 /// Base URL of the siGit Code Cloud inference endpoint. Override with
47 /// `SIGIT_CLOUD_URL` (dev: `http://localhost:8090/v1`).
48 pub fn cloud_base_url() -> String {
49 std::env::var("SIGIT_CLOUD_URL").unwrap_or_else(|_| DEFAULT_CLOUD_URL.to_string())
50 }
51
52 /// Display label for a tier, e.g. `siGit Code Cloud · Balanced`.
53 pub fn cloud_tier_label(tier: &str) -> String {
54 format!("siGit Code Cloud · {}", tier_title(tier))
55 }
56
57 /// Build a siGit Code Cloud provider for `tier`, if the user is signed in.
58 /// Returns `None` when there is no stored session, so the caller can prompt for
59 /// login rather than silently failing.
60 pub fn cloud_tier_provider(tier: &str) -> Option<ProviderConfig> {
61 let token = crate::credentials::load_token()?;
62 Some(ProviderConfig {
63 display_name: cloud_tier_label(tier),
64 base_url: cloud_base_url(),
65 api_key: token,
66 model: tier_to_model(tier),
67 })
68 }
69
70 /// Map a neutral tier name to the model id sent on the wire. Unknown values pass
71 /// through unchanged so an explicit model id still works.
72 fn tier_to_model(tier: &str) -> String {
73 match tier.trim().to_lowercase().as_str() {
74 "fast" => "onde-fast",
75 "balanced" => "onde-balanced",
76 "large" => "onde-large",
77 "mini" => "onde-mini",
78 "air" => "onde-air",
79 "pro" => "onde-pro",
80 "oke" => "onde-kkk",
81 "flux" => "onde-flux",
82 "apex" => "onde-apex",
83 "aura" => "onde-aura",
84 "orbit" => "onde-orbit",
85 "nova" => "onde-nova",
86 other => other,
87 }
88 .to_string()
89 }
90
91 /// A resolved inference provider: everything needed to build an OpenAI-compatible
92 /// client. Deliberately free of any Onde/smbCloud-specific fields.
93 #[derive(Debug, Clone)]
94 pub struct ProviderConfig {
95 /// Human-facing name shown in the UI (e.g. `siGit Code Cloud · Balanced`).
96 pub display_name: String,
97 /// API root, e.g. `https://api.openai.com/v1`.
98 pub base_url: String,
99 pub api_key: String,
100 /// Model id sent to the endpoint, e.g. `onde-balanced` or `gpt-4o-mini`.
101 pub model: String,
102 }
103
104 /// Title-case a tier name for display (`balanced` → `Balanced`).
105 pub fn tier_title(tier: &str) -> String {
106 let tier = tier.trim();
107 let mut chars = tier.chars();
108 match chars.next() {
109 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
110 None => "Balanced".to_string(),
111 }
112 }
113
114 /// Default model id used when an environment-configured provider omits one.
115 const DEFAULT_ENV_MODEL: &str = "onde-large";
116
117 /// Resolve the startup provider, or `None` to run on-device.
118 ///
119 /// This is only the explicit override (env or `providers.toml`), for power users
120 /// and BYO endpoints. Being signed in does **not** auto-select the cloud: model
121 /// choice is separate from identity, and the user picks a model or tier in
122 /// `/models`. With no override, siGit Code is local-first.
123 pub fn active_provider() -> Option<ProviderConfig> {
124 if let Some(config) = from_env() {
125 return Some(config);
126 }
127 match from_config_file() {
128 Ok(config) => config,
129 Err(error) => {
130 log::warn!("provider: ignoring providers.toml: {error}");
131 None
132 }
133 }
134 }
135
136 /// Provider from environment variables, if both URL and key are present.
137 fn from_env() -> Option<ProviderConfig> {
138 let base_url = non_empty(std::env::var("OPENAI_BASE_URL").ok())?;
139 // A base URL with no key is almost always a mistake. Warn instead of
140 // silently falling back to on-device, which looks like the cloud failed.
141 let Some(api_key) = non_empty(std::env::var("OPENAI_API_KEY").ok()) else {
142 log::warn!(
143 "provider: OPENAI_BASE_URL is set but OPENAI_API_KEY is empty/missing; \
144 staying on-device. Set OPENAI_API_KEY to use the remote provider."
145 );
146 return None;
147 };
148 let model = non_empty(std::env::var("SIGIT_MODEL").ok())
149 .unwrap_or_else(|| DEFAULT_ENV_MODEL.to_string());
150 Some(ProviderConfig {
151 display_name: format!("{model} (custom endpoint)"),
152 base_url,
153 api_key,
154 model,
155 })
156 }
157
158 // ── providers.toml ────────────────────────────────────────────────────────────────
159
160 #[derive(Debug, Deserialize)]
161 struct ProvidersFile {
162 /// Name of the profile to use.
163 active: Option<String>,
164 #[serde(default)]
165 provider: Vec<ProviderEntry>,
166 }
167
168 #[derive(Debug, Deserialize)]
169 struct ProviderEntry {
170 name: String,
171 base_url: String,
172 api_key: String,
173 model: String,
174 }
175
176 /// Path to the providers file: `$SIGIT_CONFIG_DIR` or `~/.config/sigit/providers.toml`.
177 fn config_path() -> Option<PathBuf> {
178 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
179 return Some(PathBuf::from(dir).join("providers.toml"));
180 }
181 let home = std::env::var("HOME").ok()?;
182 Some(PathBuf::from(home).join(".config/sigit/providers.toml"))
183 }
184
185 /// Load the active profile from `providers.toml`, if the file exists and names one.
186 fn from_config_file() -> Result<Option<ProviderConfig>, String> {
187 let Some(path) = config_path() else {
188 return Ok(None);
189 };
190 if !path.exists() {
191 return Ok(None);
192 }
193
194 let contents =
195 std::fs::read_to_string(&path).map_err(|error| format!("read {path:?}: {error}"))?;
196 let parsed: ProvidersFile =
197 toml::from_str(&contents).map_err(|error| format!("parse {path:?}: {error}"))?;
198
199 let Some(active) = parsed.active else {
200 return Ok(None);
201 };
202
203 let entry = parsed
204 .provider
205 .into_iter()
206 .find(|entry| entry.name == active)
207 .ok_or_else(|| format!("active profile {active:?} not found"))?;
208
209 Ok(Some(ProviderConfig {
210 display_name: format!("{} ({})", entry.name, entry.model),
211 base_url: entry.base_url,
212 api_key: entry.api_key,
213 model: entry.model,
214 }))
215 }
216
217 /// Treat an empty/whitespace string as absent.
218 fn non_empty(value: Option<String>) -> Option<String> {
219 value
220 .map(|string| string.trim().to_string())
221 .filter(|string| !string.is_empty())
222 }
223
224 #[cfg(test)]
225 mod tests {
226 use super::*;
227
228 #[test]
229 fn parses_active_profile_from_toml() {
230 let toml = r#"
231 active = "primary"
232
233 [[provider]]
234 name = "primary"
235 base_url = "https://api.example.com/v1"
236 api_key = "sk-test"
237 model = "model-a"
238
239 [[provider]]
240 name = "openai"
241 base_url = "https://api.openai.com/v1"
242 api_key = "sk-other"
243 model = "gpt-4o-mini"
244 "#;
245 let parsed: ProvidersFile = toml::from_str(toml).unwrap();
246 let active = parsed.active.unwrap();
247 let entry = parsed
248 .provider
249 .into_iter()
250 .find(|entry| entry.name == active)
251 .unwrap();
252 assert_eq!(entry.base_url, "https://api.example.com/v1");
253 assert_eq!(entry.model, "model-a");
254 }
255
256 #[test]
257 fn non_empty_filters_blanks() {
258 assert_eq!(non_empty(Some(" ".to_string())), None);
259 assert_eq!(non_empty(Some(" x ".to_string())), Some("x".to_string()));
260 assert_eq!(non_empty(None), None);
261 }
262 }