#![warn(missing_docs)]
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub use grain_id::GrainId;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ManagedBy {
Service,
Spawned,
}
mod embed;
pub use embed::{
ApiKey, DEFAULT_DIMENSION, DEFAULT_MAX_TOKENS, DeviceSettings, EmbedDeviceSetParams,
EmbedKeySetParams, EmbedModelSetParams, EmbedNote, EmbedRequest, EmbedSettingsResult,
LOCAL_MODEL, LocalModel, MAX_TOKENS, ModelSettings, ModelSource, QType, RemoteModel, Slot,
SlotModel, describe,
};
mod external;
pub use external::{
ExternalDeviceAddParams, ExternalDeviceAuthenticateParams, ExternalDeviceAuthenticateResult,
ExternalDeviceInfo, ExternalDeviceListResult, ExternalDeviceOutcome, ExternalDeviceRequest,
ExternalDeviceSelectParams, ExternalDeviceSetAppsParams, ExternalDeviceTokenResult,
describe_external_device, describe_external_outcome,
};
#[cfg(feature = "cli")]
mod cli;
#[cfg(feature = "cli")]
pub use cli::{
EmbeddingCommand, ExternalDeviceCommand, KeyCommand, LocalCommand, RemoteCommand, Switch,
read_key,
};
pub const API_VERSION: u32 = parse_major(env!("CARGO_PKG_VERSION_MAJOR"));
const fn parse_major(s: &str) -> u32 {
let bytes = s.as_bytes();
let mut i = 0;
let mut n = 0u32;
while i < bytes.len() {
n = n * 10 + (bytes[i] - b'0') as u32;
i += 1;
}
n
}
pub const BRIDGE_NAME: &str = "bridge";
pub const BRIDGE_DATA_NAME: &str = "bridge-data";
pub const ALPN: &[u8] = b"sapphire/ws/1";
pub const REGISTER: &str = "bridge.register";
pub const UNREGISTER: &str = "bridge.unregister";
pub const PEERS: &str = "bridge.peers";
pub const STATUS: &str = "bridge.status";
pub const INCOMING: &str = "bridge.incoming";
pub const INVITE: &str = "bridge.invite";
pub const JOIN: &str = "bridge.join";
pub const WORKSPACES: &str = "bridge.workspaces";
pub const WORKGROUP_CREATE: &str = "bridge.workgroup_create";
pub const DEVICE_RETIRE: &str = "bridge.device_retire";
pub const EMBED_INFO: &str = "embed.info";
pub const EMBED: &str = "embed.embed";
pub const EMBED_IMAGES: &str = "embed.embed_images";
pub const EXTERNAL_DEVICE_LIST: &str = "external_device.list";
pub const EXTERNAL_DEVICE_ADD: &str = "external_device.add";
pub const EXTERNAL_DEVICE_RETIRE: &str = "external_device.retire";
pub const EXTERNAL_DEVICE_RESTORE: &str = "external_device.restore";
pub const EXTERNAL_DEVICE_ROTATE: &str = "external_device.rotate";
pub const EXTERNAL_DEVICE_SET_APPS: &str = "external_device.set_apps";
pub const EXTERNAL_DEVICE_AUTHENTICATE: &str = "external_device.authenticate";
pub const EMBED_SETTINGS: &str = "embed.settings";
pub const EMBED_MODEL_SET: &str = "embed.model_set";
pub const EMBED_DEVICE_SET: &str = "embed.device_set";
pub const EMBED_KEY_SET: &str = "embed.key_set";
pub const EMBED_KEY_CLEAR: &str = "embed.key_clear";
pub const DEVICE_PRIORITY_SET: &str = "bridge.device_priority_set";
pub const DEFAULT_PRIORITY: u8 = 1;
fn default_priority() -> u8 {
DEFAULT_PRIORITY
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DevicePrioritySetParams {
pub selector: String,
pub priority: u8,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DevicePrioritySetResult {
pub device_id: GrainId,
pub name: String,
pub priority: u8,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InviteParams {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workgroup: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InviteResult {
pub ticket: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct WorkgroupCreateParams {
pub name: String,
pub device_name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct WorkgroupCreateResult {
pub workgroup_id: GrainId,
pub name: String,
pub device_id: GrainId,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeviceRetireParams {
pub selector: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeviceRetireResult {
pub device_id: GrainId,
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct JoinParams {
pub ticket: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_name: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct JoinResult {
pub workgroup_id: GrainId,
pub workgroup_name: String,
pub device_id: GrainId,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct WorkgroupWorkspaceInfo {
pub workspace_id: GrainId,
pub app_name: String,
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct WorkspacesResult {
pub workspaces: Vec<WorkgroupWorkspaceInfo>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct Ack {}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct WorkspaceRegistration {
pub workspace_id: GrainId,
pub root: PathBuf,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RegisterParams {
pub app_name: String,
pub exe_path: PathBuf,
pub managed_by: ManagedBy,
pub workspaces: Vec<WorkspaceRegistration>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RegisterResult {
pub device_id: GrainId,
pub node_id: String,
pub workgroup_id: GrainId,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UnregisterParams {
pub workspace_id: GrainId,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct WorkspaceRoles {
pub workspace_id: GrainId,
#[serde(default)]
pub primary: Option<GrainId>,
#[serde(default)]
pub secondary: Option<GrainId>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PeerInfo {
pub device_id: GrainId,
pub name: String,
pub node_id: String,
pub connected: bool,
#[serde(default = "default_priority")]
pub priority: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub availability: Option<u8>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PeersResult {
pub peers: Vec<PeerInfo>,
#[serde(default)]
pub roles: Vec<WorkspaceRoles>,
}
impl PeersResult {
pub fn roles_for(&self, workspace_id: GrainId) -> Option<&WorkspaceRoles> {
self.roles.iter().find(|r| r.workspace_id == workspace_id)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RouteStatus {
pub workspace_id: GrainId,
pub app_name: String,
pub root: PathBuf,
pub owner_online: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct WorkgroupStatus {
pub workgroup_id: GrainId,
pub name: String,
pub devices: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct EmbedModelInfo {
pub model: String,
pub dimension: u32,
pub template_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub images: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct EmbedInfoResult {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<EmbedModelInfo>,
#[serde(default)]
pub loaded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<EmbedNote>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EmbedParams {
pub texts: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EmbedImagesParams {
pub images: Vec<String>,
}
impl EmbedImagesParams {
pub fn new<B: AsRef<[u8]>>(images: &[B]) -> EmbedImagesParams {
use base64::Engine as _;
EmbedImagesParams {
images: images
.iter()
.map(|i| base64::engine::general_purpose::STANDARD.encode(i))
.collect(),
}
}
pub fn decode(&self) -> Result<Vec<Vec<u8>>, String> {
use base64::Engine as _;
self.images
.iter()
.enumerate()
.map(|(i, b64)| {
base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| format!("image {i}: {e}"))
})
.collect()
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EmbedResult {
pub model: String,
pub dimension: u32,
pub vectors: Vec<Vec<f32>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct StatusResult {
pub version: String,
pub node_id: String,
pub workgroup: Option<WorkgroupStatus>,
pub routes: Vec<RouteStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding: Option<EmbedInfoResult>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct IncomingParams {
pub workspace_id: GrainId,
pub peer_device_id: GrainId,
pub ticket: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
pub enum DataHeader {
Open {
workspace_id: GrainId,
device_id: GrainId,
},
Accept {
ticket: String,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DataAck {
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip<T: serde::Serialize + serde::de::DeserializeOwned>(v: &T) -> T {
serde_json::from_value(serde_json::to_value(v).unwrap()).unwrap()
}
fn id() -> GrainId {
GrainId::random()
}
#[test]
fn embed_info_round_trips() {
let info = EmbedInfoResult {
enabled: true,
model: Some(EmbedModelInfo {
model: "m".into(),
dimension: 384,
template_version: 1,
revision: None,
max_tokens: None,
images: false,
}),
loaded: true,
note: Some(EmbedNote::KeyMissing),
};
let back = round_trip(&info);
assert_eq!(back.note, Some(EmbedNote::KeyMissing));
assert!(back.enabled && back.loaded);
assert_eq!(back.model, info.model);
let off = serde_json::to_value(EmbedInfoResult::default()).unwrap();
assert_eq!(off, serde_json::json!({"enabled": false, "loaded": false}));
}
#[test]
fn status_without_embedding_deserializes() {
let json = serde_json::json!({
"version": "1", "node_id": "n", "workgroup": null, "routes": []
});
let status: StatusResult = serde_json::from_value(json).unwrap();
assert!(status.embedding.is_none());
}
#[test]
fn embed_params_wire_shape() {
let v = serde_json::to_value(EmbedParams {
texts: vec!["a".into()],
})
.unwrap();
assert_eq!(v, serde_json::json!({"texts": ["a"]}));
let r = round_trip(&EmbedResult {
model: "m".into(),
dimension: 2,
vectors: vec![vec![1.0, 2.0]],
});
assert_eq!(r.vectors, vec![vec![1.0, 2.0]]);
}
#[test]
fn registration_round_trips() {
let params = RegisterParams {
app_name: "sapphire-journal".into(),
exe_path: "/usr/bin/sapphire-journal".into(),
managed_by: ManagedBy::Spawned,
workspaces: vec![WorkspaceRegistration {
workspace_id: id(),
root: "/home/me/journal".into(),
}],
};
let back = round_trip(¶ms);
assert_eq!(back.app_name, "sapphire-journal");
assert_eq!(back.workspaces.len(), 1);
}
#[test]
fn invite_params_carry_the_cli_arguments() {
let params = InviteParams {
name: "phone".into(),
ttl: Some(600),
workgroup: None,
};
let json = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["name"], "phone");
assert_eq!(json["ttl"], 600);
assert!(json.get("workgroup").is_none());
}
#[test]
fn workspaces_result_round_trips() {
let raw = serde_json::json!({ "workspaces": [] });
let parsed: WorkspacesResult = serde_json::from_value(raw).unwrap();
assert!(parsed.workspaces.is_empty());
}
#[test]
fn an_open_header_round_trips_and_is_tagged() {
let header = DataHeader::Open {
workspace_id: id(),
device_id: id(),
};
let value = serde_json::to_value(&header).unwrap();
assert_eq!(value["kind"], "open");
assert_eq!(round_trip(&header), header);
}
#[test]
fn an_accept_header_round_trips_and_is_tagged() {
let header = DataHeader::Accept {
ticket: "t-123".into(),
};
let value = serde_json::to_value(&header).unwrap();
assert_eq!(value["kind"], "accept");
assert_eq!(round_trip(&header), header);
}
#[test]
fn an_unknown_header_kind_is_refused() {
let value = serde_json::json!({ "kind": "sideways" });
assert!(serde_json::from_value::<DataHeader>(value).is_err());
}
#[test]
fn a_data_ack_carries_its_reason_when_it_fails() {
let ack = DataAck {
ok: false,
error: Some("no such workspace".into()),
};
assert_eq!(round_trip(&ack).error.as_deref(), Some("no such workspace"));
}
#[test]
fn method_names_are_namespaced() {
for name in [
REGISTER, UNREGISTER, PEERS, STATUS, INCOMING, INVITE, JOIN, WORKSPACES,
] {
assert!(name.starts_with("bridge."), "{name}");
}
}
#[test]
fn the_crate_stays_free_of_the_workspace_and_network_stacks() {
let manifest = include_str!("../Cargo.toml");
for forbidden in [
"iroh",
"sapphire-framework-workspace",
"sapphire-framework-retrieve",
"sapphire-framework-backend",
] {
assert!(
!manifest.contains(forbidden),
"sapphire-framework-bridge-api must not depend on {forbidden}"
);
}
}
#[test]
fn the_api_version_is_the_crate_major() {
let major: u32 = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap();
assert_eq!(API_VERSION, major);
assert_eq!(API_VERSION, 2);
}
#[test]
fn workgroup_create_and_device_retire_wire_shapes() {
let p = WorkgroupCreateParams {
name: "home".into(),
device_name: "desk".into(),
};
assert_eq!(
serde_json::to_value(&p).unwrap(),
serde_json::json!({ "name": "home", "device_name": "desk" })
);
let p = DeviceRetireParams {
selector: "laptop".into(),
};
assert_eq!(
serde_json::to_value(&p).unwrap(),
serde_json::json!({ "selector": "laptop" })
);
let id = GrainId::random();
let r = WorkgroupCreateResult {
workgroup_id: id,
name: "home".into(),
device_id: id,
};
assert_eq!(round_trip(&r).name, "home");
assert_eq!(WORKGROUP_CREATE, "bridge.workgroup_create");
assert_eq!(DEVICE_RETIRE, "bridge.device_retire");
}
#[test]
fn a_2_0_peers_answer_reads_with_no_roles_and_default_priority() {
let id = GrainId::random();
let old = serde_json::json!({
"peers": [{ "device_id": id, "name": "desk", "node_id": "", "connected": true }]
});
let read: PeersResult = serde_json::from_value(old).unwrap();
assert!(read.roles.is_empty());
assert_eq!(read.peers[0].priority, DEFAULT_PRIORITY);
assert_eq!(read.peers[0].availability, None);
assert!(read.roles_for(id).is_none());
}
#[test]
fn roles_for_finds_the_workspace() {
let ws = GrainId::random();
let d = GrainId::random();
let result = PeersResult {
peers: Vec::new(),
roles: vec![WorkspaceRoles {
workspace_id: ws,
primary: Some(d),
secondary: None,
}],
};
assert_eq!(result.roles_for(ws).unwrap().primary, Some(d));
}
#[test]
fn device_priority_set_params_are_selector_and_priority() {
let p = DevicePrioritySetParams {
selector: "desk".into(),
priority: 0,
};
assert_eq!(
serde_json::to_value(&p).unwrap(),
serde_json::json!({ "selector": "desk", "priority": 0 })
);
}
}