1use std::fmt;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::EmbedInfoResult;
13
14pub const LOCAL_MODEL: &str = "Qwen/Qwen3-VL-Embedding-2B";
16pub const MAX_TOKENS: usize = 8192;
18pub const DEFAULT_DIMENSION: u32 = 1024;
20pub const DEFAULT_MAX_TOKENS: usize = 1024;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Slot {
27 Local,
29 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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct LocalModel {
46 #[serde(default = "default_local_model")]
48 pub model: String,
49 #[serde(default = "default_dimension")]
51 pub dimension: u32,
52 #[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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
79#[serde(deny_unknown_fields)]
80pub struct RemoteModel {
81 pub endpoint: String,
83 pub model: String,
85 pub dimension: u32,
87}
88
89#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
91#[serde(deny_unknown_fields)]
92pub struct ModelSettings {
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub local: Option<LocalModel>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub remote: Option<RemoteModel>,
99}
100
101impl ModelSettings {
102 pub fn is_empty(&self) -> bool {
104 self.local.is_none() && self.remote.is_none()
105 }
106
107 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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
150#[serde(rename_all = "lowercase")]
151pub enum SlotModel {
152 Local(LocalModel),
154 Remote(RemoteModel),
156}
157
158impl SlotModel {
159 pub fn slot(&self) -> Slot {
161 match self {
162 SlotModel::Local(_) => Slot::Local,
163 SlotModel::Remote(_) => Slot::Remote,
164 }
165 }
166}
167
168#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
170pub struct DeviceSettings {
171 #[serde(default)]
173 pub local_enabled: Option<bool>,
174 #[serde(default)]
176 pub remote_enabled: Option<bool>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub cache_dir: Option<PathBuf>,
180}
181
182impl DeviceSettings {
183 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#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
194#[serde(transparent)]
195pub struct ApiKey(String);
196
197impl ApiKey {
198 pub fn new(key: impl Into<String>) -> ApiKey {
200 ApiKey(key.into().trim().to_owned())
201 }
202
203 pub fn expose(&self) -> &str {
205 &self.0
206 }
207
208 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
222#[serde(rename_all = "lowercase")]
223pub enum ModelSource {
224 Workgroup,
226 Device,
228}
229
230#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
232#[serde(rename_all = "snake_case")]
233pub enum EmbedNote {
234 NotConfigured,
236 DisabledOnDevice,
238 NoAvx2,
240 KeyMissing,
242 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#[derive(Clone, Debug, Default, Deserialize, Serialize)]
260pub struct EmbedSettingsResult {
261 pub models: ModelSettings,
263 #[serde(default)]
265 pub source: Option<ModelSource>,
266 #[serde(default)]
268 pub shadowed_device_models: bool,
269 #[serde(default)]
271 pub device: DeviceSettings,
272 #[serde(default)]
274 pub local_enabled: bool,
275 #[serde(default)]
277 pub remote_enabled: bool,
278 #[serde(default)]
280 pub active: Option<Slot>,
281 #[serde(default)]
283 pub avx2: bool,
284 #[serde(default)]
286 pub key_set: bool,
287 #[serde(default)]
289 pub info: EmbedInfoResult,
290}
291
292impl EmbedSettingsResult {
293 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#[derive(Clone, Debug, Deserialize, Serialize)]
304pub struct EmbedModelSetParams {
305 pub slot: Slot,
307 #[serde(default)]
309 pub model: Option<SlotModel>,
310}
311
312#[derive(Clone, Debug, Deserialize, Serialize)]
314pub struct EmbedDeviceSetParams {
315 pub slot: Slot,
317 #[serde(default)]
319 pub enabled: Option<bool>,
320}
321
322#[derive(Clone, Debug, Deserialize, Serialize)]
324pub struct EmbedKeySetParams {
325 pub key: ApiKey,
327}
328
329#[derive(Clone, Debug)]
334pub enum EmbedRequest {
335 Show,
337 ModelSet(EmbedModelSetParams),
339 DeviceSet(EmbedDeviceSetParams),
341 KeySet(ApiKey),
343 KeyClear,
345}
346
347pub 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 assert_eq!(
492 serde_json::to_value(¶ms).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}