1#![warn(missing_docs)]
10
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15pub use grain_id::GrainId;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum ManagedBy {
24 Service,
26 Spawned,
28}
29
30mod embed;
31pub use embed::{
32 ApiKey, DEFAULT_DIMENSION, DEFAULT_MAX_TOKENS, DeviceSettings, EmbedDeviceSetParams,
33 EmbedKeySetParams, EmbedModelSetParams, EmbedNote, EmbedRequest, EmbedSettingsResult,
34 LOCAL_MODEL, LocalModel, MAX_TOKENS, ModelSettings, ModelSource, QType, RemoteModel, Slot,
35 SlotModel, describe,
36};
37
38mod external;
39pub use external::{
40 ExternalDeviceAddParams, ExternalDeviceAuthenticateParams, ExternalDeviceAuthenticateResult,
41 ExternalDeviceInfo, ExternalDeviceListResult, ExternalDeviceOutcome, ExternalDeviceRequest,
42 ExternalDeviceSelectParams, ExternalDeviceSetAppsParams, ExternalDeviceTokenResult,
43 describe_external_device, describe_external_outcome,
44};
45
46#[cfg(feature = "cli")]
47mod cli;
48#[cfg(feature = "cli")]
49pub use cli::{
50 EmbeddingCommand, ExternalDeviceCommand, KeyCommand, LocalCommand, RemoteCommand, Switch,
51 read_key,
52};
53
54pub const API_VERSION: u32 = parse_major(env!("CARGO_PKG_VERSION_MAJOR"));
60
61const fn parse_major(s: &str) -> u32 {
63 let bytes = s.as_bytes();
64 let mut i = 0;
65 let mut n = 0u32;
66 while i < bytes.len() {
67 n = n * 10 + (bytes[i] - b'0') as u32;
68 i += 1;
69 }
70 n
71}
72
73pub const BRIDGE_NAME: &str = "bridge";
75pub const BRIDGE_DATA_NAME: &str = "bridge-data";
77pub const ALPN: &[u8] = b"sapphire/ws/1";
79
80pub const REGISTER: &str = "bridge.register";
82pub const UNREGISTER: &str = "bridge.unregister";
84pub const PEERS: &str = "bridge.peers";
86pub const STATUS: &str = "bridge.status";
88pub const INCOMING: &str = "bridge.incoming";
90pub const INVITE: &str = "bridge.invite";
92pub const JOIN: &str = "bridge.join";
94pub const WORKSPACES: &str = "bridge.workspaces";
96pub const WORKGROUP_CREATE: &str = "bridge.workgroup_create";
98pub const DEVICE_RETIRE: &str = "bridge.device_retire";
100pub const EMBED_INFO: &str = "embed.info";
102pub const EMBED: &str = "embed.embed";
104pub const EMBED_IMAGES: &str = "embed.embed_images";
107pub const EXTERNAL_DEVICE_LIST: &str = "external_device.list";
109pub const EXTERNAL_DEVICE_ADD: &str = "external_device.add";
111pub const EXTERNAL_DEVICE_RETIRE: &str = "external_device.retire";
113pub const EXTERNAL_DEVICE_RESTORE: &str = "external_device.restore";
115pub const EXTERNAL_DEVICE_ROTATE: &str = "external_device.rotate";
117pub const EXTERNAL_DEVICE_SET_APPS: &str = "external_device.set_apps";
119pub const EXTERNAL_DEVICE_AUTHENTICATE: &str = "external_device.authenticate";
121pub const EMBED_SETTINGS: &str = "embed.settings";
123pub const EMBED_MODEL_SET: &str = "embed.model_set";
125pub const EMBED_DEVICE_SET: &str = "embed.device_set";
127pub const EMBED_KEY_SET: &str = "embed.key_set";
129pub const EMBED_KEY_CLEAR: &str = "embed.key_clear";
131
132pub const DEVICE_PRIORITY_SET: &str = "bridge.device_priority_set";
134
135pub const DEFAULT_PRIORITY: u8 = 1;
138
139fn default_priority() -> u8 {
140 DEFAULT_PRIORITY
141}
142
143#[derive(Clone, Debug, Deserialize, Serialize)]
145pub struct DevicePrioritySetParams {
146 pub selector: String,
148 pub priority: u8,
150}
151
152#[derive(Clone, Debug, Deserialize, Serialize)]
154pub struct DevicePrioritySetResult {
155 pub device_id: GrainId,
157 pub name: String,
159 pub priority: u8,
161}
162
163#[derive(Clone, Debug, Deserialize, Serialize)]
165pub struct InviteParams {
166 pub name: String,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub ttl: Option<u64>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub workgroup: Option<String>,
174}
175
176#[derive(Clone, Debug, Deserialize, Serialize)]
178pub struct InviteResult {
179 pub ticket: String,
181}
182
183#[derive(Clone, Debug, Deserialize, Serialize)]
185pub struct WorkgroupCreateParams {
186 pub name: String,
188 pub device_name: String,
190}
191
192#[derive(Clone, Debug, Deserialize, Serialize)]
194pub struct WorkgroupCreateResult {
195 pub workgroup_id: GrainId,
197 pub name: String,
199 pub device_id: GrainId,
201}
202
203#[derive(Clone, Debug, Deserialize, Serialize)]
205pub struct DeviceRetireParams {
206 pub selector: String,
208}
209
210#[derive(Clone, Debug, Deserialize, Serialize)]
212pub struct DeviceRetireResult {
213 pub device_id: GrainId,
215 pub name: String,
217}
218
219#[derive(Clone, Debug, Deserialize, Serialize)]
221pub struct JoinParams {
222 pub ticket: String,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub device_name: Option<String>,
227}
228
229#[derive(Clone, Debug, Deserialize, Serialize)]
231pub struct JoinResult {
232 pub workgroup_id: GrainId,
234 pub workgroup_name: String,
236 pub device_id: GrainId,
238}
239
240#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
242pub struct WorkgroupWorkspaceInfo {
243 pub workspace_id: GrainId,
245 pub app_name: String,
247 pub name: String,
249}
250
251#[derive(Clone, Debug, Deserialize, Serialize)]
253pub struct WorkspacesResult {
254 pub workspaces: Vec<WorkgroupWorkspaceInfo>,
256}
257
258#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
260pub struct Ack {}
261
262#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
264pub struct WorkspaceRegistration {
265 pub workspace_id: GrainId,
267 pub root: PathBuf,
269}
270
271#[derive(Clone, Debug, Deserialize, Serialize)]
273pub struct RegisterParams {
274 pub app_name: String,
276 pub exe_path: PathBuf,
278 pub managed_by: ManagedBy,
280 pub workspaces: Vec<WorkspaceRegistration>,
282}
283
284#[derive(Clone, Debug, Deserialize, Serialize)]
286pub struct RegisterResult {
287 pub device_id: GrainId,
289 pub node_id: String,
291 pub workgroup_id: GrainId,
293}
294
295#[derive(Clone, Debug, Deserialize, Serialize)]
297pub struct UnregisterParams {
298 pub workspace_id: GrainId,
300}
301
302#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
304pub struct WorkspaceRoles {
305 pub workspace_id: GrainId,
307 #[serde(default)]
309 pub primary: Option<GrainId>,
310 #[serde(default)]
312 pub secondary: Option<GrainId>,
313}
314
315#[derive(Clone, Debug, Deserialize, Serialize)]
317pub struct PeerInfo {
318 pub device_id: GrainId,
320 pub name: String,
322 pub node_id: String,
324 pub connected: bool,
326 #[serde(default = "default_priority")]
328 pub priority: u8,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub availability: Option<u8>,
332}
333
334#[derive(Clone, Debug, Deserialize, Serialize)]
336pub struct PeersResult {
337 pub peers: Vec<PeerInfo>,
339 #[serde(default)]
342 pub roles: Vec<WorkspaceRoles>,
343}
344
345impl PeersResult {
346 pub fn roles_for(&self, workspace_id: GrainId) -> Option<&WorkspaceRoles> {
348 self.roles.iter().find(|r| r.workspace_id == workspace_id)
349 }
350}
351
352#[derive(Clone, Debug, Deserialize, Serialize)]
354pub struct RouteStatus {
355 pub workspace_id: GrainId,
357 pub app_name: String,
359 pub root: PathBuf,
361 pub owner_online: bool,
363}
364
365#[derive(Clone, Debug, Deserialize, Serialize)]
367pub struct WorkgroupStatus {
368 pub workgroup_id: GrainId,
370 pub name: String,
372 pub devices: usize,
374}
375
376#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
378pub struct EmbedModelInfo {
379 pub model: String,
381 pub dimension: u32,
383 pub template_version: u32,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub revision: Option<String>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub max_tokens: Option<u32>,
391 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
394 pub images: bool,
395}
396
397#[derive(Clone, Debug, Default, Deserialize, Serialize)]
399pub struct EmbedInfoResult {
400 pub enabled: bool,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub model: Option<EmbedModelInfo>,
405 #[serde(default)]
407 pub loaded: bool,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub note: Option<EmbedNote>,
411}
412
413#[derive(Clone, Debug, Deserialize, Serialize)]
415pub struct EmbedParams {
416 pub texts: Vec<String>,
418}
419
420#[derive(Clone, Debug, Deserialize, Serialize)]
422pub struct EmbedImagesParams {
423 pub images: Vec<String>,
425}
426
427impl EmbedImagesParams {
428 pub fn new<B: AsRef<[u8]>>(images: &[B]) -> EmbedImagesParams {
430 use base64::Engine as _;
431 EmbedImagesParams {
432 images: images
433 .iter()
434 .map(|i| base64::engine::general_purpose::STANDARD.encode(i))
435 .collect(),
436 }
437 }
438
439 pub fn decode(&self) -> Result<Vec<Vec<u8>>, String> {
441 use base64::Engine as _;
442 self.images
443 .iter()
444 .enumerate()
445 .map(|(i, b64)| {
446 base64::engine::general_purpose::STANDARD
447 .decode(b64)
448 .map_err(|e| format!("image {i}: {e}"))
449 })
450 .collect()
451 }
452}
453
454#[derive(Clone, Debug, Deserialize, Serialize)]
456pub struct EmbedResult {
457 pub model: String,
459 pub dimension: u32,
461 pub vectors: Vec<Vec<f32>>,
463}
464
465#[derive(Clone, Debug, Deserialize, Serialize)]
467pub struct StatusResult {
468 pub version: String,
470 pub node_id: String,
472 pub workgroup: Option<WorkgroupStatus>,
474 pub routes: Vec<RouteStatus>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub embedding: Option<EmbedInfoResult>,
479}
480
481#[derive(Clone, Debug, Deserialize, Serialize)]
483pub struct IncomingParams {
484 pub workspace_id: GrainId,
486 pub peer_device_id: GrainId,
488 pub ticket: String,
493}
494
495#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
497#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
498pub enum DataHeader {
499 Open {
501 workspace_id: GrainId,
503 device_id: GrainId,
505 },
506 Accept {
508 ticket: String,
510 },
511}
512
513#[derive(Clone, Debug, Deserialize, Serialize)]
515pub struct DataAck {
516 pub ok: bool,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub error: Option<String>,
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned>(v: &T) -> T {
528 serde_json::from_value(serde_json::to_value(v).unwrap()).unwrap()
529 }
530
531 fn id() -> GrainId {
532 GrainId::random()
533 }
534
535 #[test]
536 fn embed_info_round_trips() {
537 let info = EmbedInfoResult {
538 enabled: true,
539 model: Some(EmbedModelInfo {
540 model: "m".into(),
541 dimension: 384,
542 template_version: 1,
543 revision: None,
544 max_tokens: None,
545 images: false,
546 }),
547 loaded: true,
548 note: Some(EmbedNote::KeyMissing),
549 };
550 let back = round_trip(&info);
551 assert_eq!(back.note, Some(EmbedNote::KeyMissing));
552 assert!(back.enabled && back.loaded);
553 assert_eq!(back.model, info.model);
554 let off = serde_json::to_value(EmbedInfoResult::default()).unwrap();
555 assert_eq!(off, serde_json::json!({"enabled": false, "loaded": false}));
556 }
557
558 #[test]
559 fn status_without_embedding_deserializes() {
560 let json = serde_json::json!({
561 "version": "1", "node_id": "n", "workgroup": null, "routes": []
562 });
563 let status: StatusResult = serde_json::from_value(json).unwrap();
564 assert!(status.embedding.is_none());
565 }
566
567 #[test]
568 fn embed_params_wire_shape() {
569 let v = serde_json::to_value(EmbedParams {
570 texts: vec!["a".into()],
571 })
572 .unwrap();
573 assert_eq!(v, serde_json::json!({"texts": ["a"]}));
574 let r = round_trip(&EmbedResult {
575 model: "m".into(),
576 dimension: 2,
577 vectors: vec![vec![1.0, 2.0]],
578 });
579 assert_eq!(r.vectors, vec![vec![1.0, 2.0]]);
580 }
581
582 #[test]
583 fn registration_round_trips() {
584 let params = RegisterParams {
585 app_name: "sapphire-journal".into(),
586 exe_path: "/usr/bin/sapphire-journal".into(),
587 managed_by: ManagedBy::Spawned,
588 workspaces: vec![WorkspaceRegistration {
589 workspace_id: id(),
590 root: "/home/me/journal".into(),
591 }],
592 };
593 let back = round_trip(¶ms);
594 assert_eq!(back.app_name, "sapphire-journal");
595 assert_eq!(back.workspaces.len(), 1);
596 }
597
598 #[test]
599 fn invite_params_carry_the_cli_arguments() {
600 let params = InviteParams {
601 name: "phone".into(),
602 ttl: Some(600),
603 workgroup: None,
604 };
605 let json = serde_json::to_value(¶ms).unwrap();
607 assert_eq!(json["name"], "phone");
608 assert_eq!(json["ttl"], 600);
609 assert!(json.get("workgroup").is_none());
610 }
611
612 #[test]
613 fn workspaces_result_round_trips() {
614 let raw = serde_json::json!({ "workspaces": [] });
615 let parsed: WorkspacesResult = serde_json::from_value(raw).unwrap();
616 assert!(parsed.workspaces.is_empty());
617 }
618
619 #[test]
620 fn an_open_header_round_trips_and_is_tagged() {
621 let header = DataHeader::Open {
622 workspace_id: id(),
623 device_id: id(),
624 };
625 let value = serde_json::to_value(&header).unwrap();
626 assert_eq!(value["kind"], "open");
627 assert_eq!(round_trip(&header), header);
628 }
629
630 #[test]
631 fn an_accept_header_round_trips_and_is_tagged() {
632 let header = DataHeader::Accept {
633 ticket: "t-123".into(),
634 };
635 let value = serde_json::to_value(&header).unwrap();
636 assert_eq!(value["kind"], "accept");
637 assert_eq!(round_trip(&header), header);
638 }
639
640 #[test]
641 fn an_unknown_header_kind_is_refused() {
642 let value = serde_json::json!({ "kind": "sideways" });
643 assert!(serde_json::from_value::<DataHeader>(value).is_err());
644 }
645
646 #[test]
647 fn a_data_ack_carries_its_reason_when_it_fails() {
648 let ack = DataAck {
649 ok: false,
650 error: Some("no such workspace".into()),
651 };
652 assert_eq!(round_trip(&ack).error.as_deref(), Some("no such workspace"));
653 }
654
655 #[test]
656 fn method_names_are_namespaced() {
657 for name in [
658 REGISTER, UNREGISTER, PEERS, STATUS, INCOMING, INVITE, JOIN, WORKSPACES,
659 ] {
660 assert!(name.starts_with("bridge."), "{name}");
661 }
662 }
663
664 #[test]
665 fn the_crate_stays_free_of_the_workspace_and_network_stacks() {
666 let manifest = include_str!("../Cargo.toml");
667 for forbidden in [
668 "iroh",
669 "sapphire-framework-workspace",
670 "sapphire-framework-retrieve",
671 "sapphire-framework-backend",
672 ] {
673 assert!(
674 !manifest.contains(forbidden),
675 "sapphire-framework-bridge-api must not depend on {forbidden}"
676 );
677 }
678 }
679
680 #[test]
681 fn the_api_version_is_the_crate_major() {
682 let major: u32 = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap();
683 assert_eq!(API_VERSION, major);
684 assert_eq!(API_VERSION, 2);
685 }
686
687 #[test]
688 fn workgroup_create_and_device_retire_wire_shapes() {
689 let p = WorkgroupCreateParams {
690 name: "home".into(),
691 device_name: "desk".into(),
692 };
693 assert_eq!(
694 serde_json::to_value(&p).unwrap(),
695 serde_json::json!({ "name": "home", "device_name": "desk" })
696 );
697 let p = DeviceRetireParams {
698 selector: "laptop".into(),
699 };
700 assert_eq!(
701 serde_json::to_value(&p).unwrap(),
702 serde_json::json!({ "selector": "laptop" })
703 );
704 let id = GrainId::random();
705 let r = WorkgroupCreateResult {
706 workgroup_id: id,
707 name: "home".into(),
708 device_id: id,
709 };
710 assert_eq!(round_trip(&r).name, "home");
711 assert_eq!(WORKGROUP_CREATE, "bridge.workgroup_create");
712 assert_eq!(DEVICE_RETIRE, "bridge.device_retire");
713 }
714
715 #[test]
716 fn a_2_0_peers_answer_reads_with_no_roles_and_default_priority() {
717 let id = GrainId::random();
718 let old = serde_json::json!({
719 "peers": [{ "device_id": id, "name": "desk", "node_id": "", "connected": true }]
720 });
721
722 let read: PeersResult = serde_json::from_value(old).unwrap();
723
724 assert!(read.roles.is_empty());
725 assert_eq!(read.peers[0].priority, DEFAULT_PRIORITY);
726 assert_eq!(read.peers[0].availability, None);
727 assert!(read.roles_for(id).is_none());
728 }
729
730 #[test]
731 fn roles_for_finds_the_workspace() {
732 let ws = GrainId::random();
733 let d = GrainId::random();
734 let result = PeersResult {
735 peers: Vec::new(),
736 roles: vec![WorkspaceRoles {
737 workspace_id: ws,
738 primary: Some(d),
739 secondary: None,
740 }],
741 };
742
743 assert_eq!(result.roles_for(ws).unwrap().primary, Some(d));
744 }
745
746 #[test]
747 fn device_priority_set_params_are_selector_and_priority() {
748 let p = DevicePrioritySetParams {
749 selector: "desk".into(),
750 priority: 0,
751 };
752 assert_eq!(
753 serde_json::to_value(&p).unwrap(),
754 serde_json::json!({ "selector": "desk", "priority": 0 })
755 );
756 }
757}