Skip to main content

sapphire_framework_bridge_api/
embed.rs

1//! Embedding settings: what the `embed.*` settings methods carry.
2//!
3//! A workgroup configures at most one *local* model and at most one *remote* one; each
4//! device switches each slot on or off, and keeps the remote slot's API key to itself.
5//! See `docs/superpowers/specs/2026-10-10-embedding-settings-design.md`.
6
7use std::fmt;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::EmbedInfoResult;
13
14/// The only local model for now.
15pub const LOCAL_MODEL: &str = "Qwen/Qwen3-VL-Embedding-2B";
16/// Upper bound on [`LocalModel::max_tokens`].
17pub const MAX_TOKENS: usize = 8192;
18/// [`LocalModel::dimension`]'s default, and the dimension #183 settled on.
19pub const DEFAULT_DIMENSION: u32 = 1024;
20/// [`LocalModel::max_tokens`]'s default.
21pub const DEFAULT_MAX_TOKENS: usize = 1024;
22
23/// One of the two model slots.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Slot {
27    /// Computed on this device's CPU.
28    Local,
29    /// An OpenAI-compatible `/v1/embeddings` endpoint.
30    Remote,
31}
32
33impl fmt::Display for Slot {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(match self {
36            Slot::Local => "local",
37            Slot::Remote => "remote",
38        })
39    }
40}
41
42/// The local slot's model.
43#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct LocalModel {
46    /// The model's name; only [`LOCAL_MODEL`] is supported.
47    #[serde(default = "default_local_model")]
48    pub model: String,
49    /// Output dimension; vectors are cut to it (MRL) and normalized again.
50    #[serde(default = "default_dimension")]
51    pub dimension: u32,
52    /// Content tokens kept before the template is applied.
53    #[serde(default = "default_max_tokens")]
54    pub max_tokens: usize,
55}
56
57fn default_local_model() -> String {
58    LOCAL_MODEL.to_owned()
59}
60fn default_dimension() -> u32 {
61    DEFAULT_DIMENSION
62}
63fn default_max_tokens() -> usize {
64    DEFAULT_MAX_TOKENS
65}
66
67impl Default for LocalModel {
68    fn default() -> Self {
69        LocalModel {
70            model: default_local_model(),
71            dimension: DEFAULT_DIMENSION,
72            max_tokens: DEFAULT_MAX_TOKENS,
73        }
74    }
75}
76
77/// The remote slot's model.
78#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
79#[serde(deny_unknown_fields)]
80pub struct RemoteModel {
81    /// The endpoint's base URL; requests go to `<endpoint>/v1/embeddings`.
82    pub endpoint: String,
83    /// The model's name, as the endpoint knows it.
84    pub model: String,
85    /// Output dimension; longer vectors are cut to it (MRL) and normalized again.
86    pub dimension: u32,
87}
88
89/// The two slots: the workgroup's synced `embedding.toml`, or a host's own without one.
90#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
91#[serde(deny_unknown_fields)]
92pub struct ModelSettings {
93    /// The local slot.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub local: Option<LocalModel>,
96    /// The remote slot.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub remote: Option<RemoteModel>,
99}
100
101impl ModelSettings {
102    /// Neither slot is configured.
103    pub fn is_empty(&self) -> bool {
104        self.local.is_none() && self.remote.is_none()
105    }
106
107    /// Check both slots; the message names the field at fault.
108    pub fn validate(&self) -> Result<(), String> {
109        if let Some(local) = &self.local {
110            if local.model != LOCAL_MODEL {
111                return Err(format!(
112                    "local: only `{LOCAL_MODEL}` is supported, not `{}`",
113                    local.model
114                ));
115            }
116            if !(64..=2048).contains(&local.dimension) {
117                return Err(format!(
118                    "local: `dimension` = {} is out of range (64..=2048)",
119                    local.dimension
120                ));
121            }
122            if !(1..=MAX_TOKENS).contains(&local.max_tokens) {
123                return Err(format!(
124                    "local: `max_tokens` = {} is out of range (1..={MAX_TOKENS})",
125                    local.max_tokens
126                ));
127            }
128        }
129        if let Some(remote) = &self.remote {
130            if !(remote.endpoint.starts_with("http://") || remote.endpoint.starts_with("https://"))
131            {
132                return Err(format!(
133                    "remote: `endpoint` = `{}` is not an http(s) URL",
134                    remote.endpoint
135                ));
136            }
137            if remote.model.trim().is_empty() {
138                return Err("remote: `model` must not be empty".to_owned());
139            }
140            if remote.dimension == 0 {
141                return Err("remote: `dimension` must be at least 1".to_owned());
142            }
143        }
144        Ok(())
145    }
146}
147
148/// One slot's model, for [`EmbedModelSetParams`].
149#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
150#[serde(rename_all = "lowercase")]
151pub enum SlotModel {
152    /// The local slot's model.
153    Local(LocalModel),
154    /// The remote slot's model.
155    Remote(RemoteModel),
156}
157
158impl SlotModel {
159    /// The slot this model belongs in.
160    pub fn slot(&self) -> Slot {
161        match self {
162            SlotModel::Local(_) => Slot::Local,
163            SlotModel::Remote(_) => Slot::Remote,
164        }
165    }
166}
167
168/// This device's own settings, never synced.
169#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
170pub struct DeviceSettings {
171    /// The local slot's switch; `None` is auto (off without AVX2).
172    #[serde(default)]
173    pub local_enabled: Option<bool>,
174    /// The remote slot's switch; `None` is auto (on).
175    #[serde(default)]
176    pub remote_enabled: Option<bool>,
177    /// Where the local model's files are cached, when not the bridge's default.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub cache_dir: Option<PathBuf>,
180}
181
182impl DeviceSettings {
183    /// One slot's switch.
184    pub fn enabled(&self, slot: Slot) -> Option<bool> {
185        match slot {
186            Slot::Local => self.local_enabled,
187            Slot::Remote => self.remote_enabled,
188        }
189    }
190}
191
192/// The remote slot's API key. Never printed: `Debug` shows `ApiKey(..)`.
193#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
194#[serde(transparent)]
195pub struct ApiKey(String);
196
197impl ApiKey {
198    /// Wrap a key; surrounding whitespace (a trailing newline from a pipe) is dropped.
199    pub fn new(key: impl Into<String>) -> ApiKey {
200        ApiKey(key.into().trim().to_owned())
201    }
202
203    /// The key itself, for the one place that sends it.
204    pub fn expose(&self) -> &str {
205        &self.0
206    }
207
208    /// Whether the key is empty.
209    pub fn is_empty(&self) -> bool {
210        self.0.is_empty()
211    }
212}
213
214impl fmt::Debug for ApiKey {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        f.write_str("ApiKey(..)")
217    }
218}
219
220/// Where the model settings in effect come from.
221#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
222#[serde(rename_all = "lowercase")]
223pub enum ModelSource {
224    /// The workgroup's synced `embedding.toml`.
225    Workgroup,
226    /// This host's own `[models]`, because it has no workgroup or the workgroup has none.
227    Device,
228}
229
230/// Why a bridge does not embed, or what it lacks while it does.
231#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
232#[serde(rename_all = "snake_case")]
233pub enum EmbedNote {
234    /// No slot is configured.
235    NotConfigured,
236    /// Every configured slot is switched off on this device.
237    DisabledOnDevice,
238    /// Only the local slot is configured, and auto turned it off: no AVX2.
239    NoAvx2,
240    /// The remote slot is active without an API key (the call is still attempted).
241    KeyMissing,
242    /// The settings could not be read or are invalid.
243    Invalid(String),
244}
245
246impl fmt::Display for EmbedNote {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        match self {
249            EmbedNote::NotConfigured => f.write_str("no model is configured"),
250            EmbedNote::DisabledOnDevice => f.write_str("turned off on this device"),
251            EmbedNote::NoAvx2 => f.write_str("the local model needs AVX2, which this CPU lacks"),
252            EmbedNote::KeyMissing => f.write_str("no API key is set for the remote model"),
253            EmbedNote::Invalid(why) => write!(f, "invalid settings: {why}"),
254        }
255    }
256}
257
258/// Result of every `embed.*` settings method: the settings, and what they resolve to.
259#[derive(Clone, Debug, Default, Deserialize, Serialize)]
260pub struct EmbedSettingsResult {
261    /// The model settings in effect.
262    pub models: ModelSettings,
263    /// Where they come from; `None` when there are none.
264    #[serde(default)]
265    pub source: Option<ModelSource>,
266    /// This host's own `[models]` exists but the workgroup's file wins.
267    #[serde(default)]
268    pub shadowed_device_models: bool,
269    /// This device's own settings, as written.
270    #[serde(default)]
271    pub device: DeviceSettings,
272    /// The local slot's effective switch.
273    #[serde(default)]
274    pub local_enabled: bool,
275    /// The remote slot's effective switch.
276    #[serde(default)]
277    pub remote_enabled: bool,
278    /// The slot the bridge serves.
279    #[serde(default)]
280    pub active: Option<Slot>,
281    /// Whether this CPU has AVX2 (always true off x86_64).
282    #[serde(default)]
283    pub avx2: bool,
284    /// Whether an API key is stored on this device.
285    #[serde(default)]
286    pub key_set: bool,
287    /// What `embed.info` answers now.
288    #[serde(default)]
289    pub info: EmbedInfoResult,
290}
291
292impl EmbedSettingsResult {
293    /// One slot's effective switch.
294    pub fn enabled(&self, slot: Slot) -> bool {
295        match slot {
296            Slot::Local => self.local_enabled,
297            Slot::Remote => self.remote_enabled,
298        }
299    }
300}
301
302/// Parameters of [`EMBED_MODEL_SET`](crate::EMBED_MODEL_SET).
303#[derive(Clone, Debug, Deserialize, Serialize)]
304pub struct EmbedModelSetParams {
305    /// The slot to set or clear.
306    pub slot: Slot,
307    /// Its new model; `None` clears the slot. Must belong to `slot`.
308    #[serde(default)]
309    pub model: Option<SlotModel>,
310}
311
312/// Parameters of [`EMBED_DEVICE_SET`](crate::EMBED_DEVICE_SET).
313#[derive(Clone, Debug, Deserialize, Serialize)]
314pub struct EmbedDeviceSetParams {
315    /// The slot to switch.
316    pub slot: Slot,
317    /// `None` is auto.
318    #[serde(default)]
319    pub enabled: Option<bool>,
320}
321
322/// Parameters of [`EMBED_KEY_SET`](crate::EMBED_KEY_SET). `Debug` hides the key.
323#[derive(Clone, Debug, Deserialize, Serialize)]
324pub struct EmbedKeySetParams {
325    /// The key.
326    pub key: ApiKey,
327}
328
329/// One settings request, as either CLI builds it from its arguments.
330///
331/// The bridge's CLI carries it out on the files when no bridge is running; both CLIs send
332/// it to a running bridge with [`BridgeClient::embed_request`](crate::BridgeClient::embed_request).
333#[derive(Clone, Debug)]
334pub enum EmbedRequest {
335    /// Read the settings.
336    Show,
337    /// Set or clear a slot's model.
338    ModelSet(EmbedModelSetParams),
339    /// Switch a slot on this device.
340    DeviceSet(EmbedDeviceSetParams),
341    /// Store the API key.
342    KeySet(ApiKey),
343    /// Remove the API key.
344    KeyClear,
345}
346
347/// What `embedding show` prints, one line each.
348pub fn describe(s: &EmbedSettingsResult) -> Vec<String> {
349    let mut lines = Vec::new();
350    lines.push(match s.source {
351        Some(ModelSource::Workgroup) if s.shadowed_device_models => {
352            "models: the workgroup's (this device's own [models] is ignored)".to_owned()
353        }
354        Some(ModelSource::Workgroup) => "models: the workgroup's".to_owned(),
355        Some(ModelSource::Device) => "models: this device's own".to_owned(),
356        None => "models: none".to_owned(),
357    });
358    let switch = |slot: Slot| -> String {
359        let on = if s.enabled(slot) { "on" } else { "off" };
360        match s.device.enabled(slot) {
361            Some(_) => on.to_owned(),
362            None if slot == Slot::Local && !s.avx2 => format!("{on} (auto: no AVX2)"),
363            None => format!("{on} (auto)"),
364        }
365    };
366    lines.push(match &s.models.local {
367        Some(m) => format!(
368            "local:  {} ({} dimensions, {} tokens) — this device: {}",
369            m.model,
370            m.dimension,
371            m.max_tokens,
372            switch(Slot::Local)
373        ),
374        None => "local:  not configured".to_owned(),
375    });
376    lines.push(match &s.models.remote {
377        Some(m) => format!(
378            "remote: {} at {} ({} dimensions) — this device: {}, key {}",
379            m.model,
380            m.endpoint,
381            m.dimension,
382            switch(Slot::Remote),
383            if s.key_set { "set" } else { "not set" }
384        ),
385        None => format!(
386            "remote: not configured{}",
387            if s.key_set { " (a key is set)" } else { "" }
388        ),
389    });
390    let state = match (&s.active, &s.info.note) {
391        (Some(slot), None) => format!(
392            "active: {slot}, {}",
393            if s.info.loaded {
394                "loaded"
395            } else {
396                "not loaded"
397            }
398        ),
399        (Some(slot), Some(note)) => format!("active: {slot} — {note}"),
400        (None, Some(note)) => format!("active: none — {note}"),
401        (None, None) => "active: none".to_owned(),
402    };
403    lines.push(state);
404    lines
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn remote() -> RemoteModel {
412        RemoteModel {
413            endpoint: "https://api.example.com".into(),
414            model: "m".into(),
415            dimension: 8,
416        }
417    }
418
419    #[test]
420    fn local_defaults_validate() {
421        let s = ModelSettings {
422            local: Some(LocalModel::default()),
423            remote: Some(remote()),
424        };
425        assert_eq!(s.validate(), Ok(()));
426    }
427
428    #[test]
429    fn validation_keeps_the_local_rules() {
430        let mut local = LocalModel {
431            dimension: 4096,
432            ..LocalModel::default()
433        };
434        let check = |l: &LocalModel| {
435            ModelSettings {
436                local: Some(l.clone()),
437                remote: None,
438            }
439            .validate()
440        };
441        assert!(check(&local).is_err());
442        local.dimension = 1024;
443        local.max_tokens = MAX_TOKENS + 1;
444        assert!(check(&local).is_err());
445        local.max_tokens = MAX_TOKENS;
446        assert!(check(&local).is_ok());
447        local.model = "other".into();
448        assert!(check(&local).is_err());
449    }
450
451    #[test]
452    fn validation_checks_the_remote_slot() {
453        let check = |r: RemoteModel| {
454            ModelSettings {
455                local: None,
456                remote: Some(r),
457            }
458            .validate()
459        };
460        assert!(
461            check(RemoteModel {
462                endpoint: "api.example.com".into(),
463                ..remote()
464            })
465            .is_err()
466        );
467        assert!(
468            check(RemoteModel {
469                model: " ".into(),
470                ..remote()
471            })
472            .is_err()
473        );
474        assert!(
475            check(RemoteModel {
476                dimension: 0,
477                ..remote()
478            })
479            .is_err()
480        );
481    }
482
483    #[test]
484    fn the_key_never_prints() {
485        let key = ApiKey::new("sk-secret\n");
486        assert_eq!(key.expose(), "sk-secret");
487        let params = EmbedKeySetParams { key };
488        let shown = format!("{params:?} {:?}", EmbedRequest::KeySet(params.key.clone()));
489        assert!(!shown.contains("sk-secret"), "{shown}");
490        // The wire form carries it, plainly.
491        assert_eq!(
492            serde_json::to_value(&params).unwrap(),
493            serde_json::json!({ "key": "sk-secret" })
494        );
495    }
496
497    #[test]
498    fn slot_models_are_tagged_by_slot() {
499        let v = serde_json::to_value(SlotModel::Remote(remote())).unwrap();
500        assert_eq!(v["remote"]["model"], "m");
501        let back: SlotModel = serde_json::from_value(v).unwrap();
502        assert_eq!(back.slot(), Slot::Remote);
503    }
504
505    #[test]
506    fn an_info_without_a_note_still_parses() {
507        let info: EmbedInfoResult =
508            serde_json::from_value(serde_json::json!({ "enabled": false })).unwrap();
509        assert_eq!(info.note, None);
510    }
511
512    #[test]
513    fn describe_names_the_source_switches_and_state() {
514        let s = EmbedSettingsResult {
515            models: ModelSettings {
516                local: Some(LocalModel::default()),
517                remote: Some(remote()),
518            },
519            source: Some(ModelSource::Workgroup),
520            local_enabled: false,
521            remote_enabled: true,
522            active: Some(Slot::Remote),
523            avx2: false,
524            key_set: false,
525            info: EmbedInfoResult {
526                enabled: true,
527                note: Some(EmbedNote::KeyMissing),
528                ..EmbedInfoResult::default()
529            },
530            ..EmbedSettingsResult::default()
531        };
532        let lines = describe(&s);
533        assert_eq!(lines[0], "models: the workgroup's");
534        assert!(
535            lines[1].ends_with("this device: off (auto: no AVX2)"),
536            "{}",
537            lines[1]
538        );
539        assert!(
540            lines[2].ends_with("this device: on (auto), key not set"),
541            "{}",
542            lines[2]
543        );
544        assert_eq!(
545            lines[3],
546            "active: remote — no API key is set for the remote model"
547        );
548    }
549
550    #[test]
551    fn describe_without_settings() {
552        let s = EmbedSettingsResult {
553            avx2: true,
554            info: EmbedInfoResult {
555                note: Some(EmbedNote::NotConfigured),
556                ..EmbedInfoResult::default()
557            },
558            ..EmbedSettingsResult::default()
559        };
560        assert_eq!(
561            describe(&s),
562            vec![
563                "models: none",
564                "local:  not configured",
565                "remote: not configured",
566                "active: none — no model is configured",
567            ]
568        );
569    }
570}