Skip to content

Ship real client and serving SDKs across Swift, Kotlin, and Node.js - #634

Merged
i386 merged 29 commits into
mainfrom
jd/sdk
May 23, 2026
Merged

Ship real client and serving SDKs across Swift, Kotlin, and Node.js#634
i386 merged 29 commits into
mainfrom
jd/sdk

Conversation

@i386

@i386 i386 commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Users can now build MeshLLM applications against real SDK surfaces instead of examples or wrappers that only exercise partial behavior. This PR turns the SDK work into a usable client and serving stack across Swift, Kotlin, and Node.js, with model management, embedded serving load/unload, native runtime packaging, and CI checks to keep the contract aligned.

What changed

  • Reworked the SDK contract around a Node API with client, model management, inference, and serving surfaces.
  • Added model search, model info, installed model listing, download, capabilities, serving status, load, and unload APIs.
  • Removed the old MeshClient-style wrapper direction from Swift/Kotlin and aligned naming with language namespaces/modules.
  • Renamed SDK crates to the mesh-llm-* shape, including mesh-llm-api, mesh-llm-ffi, mesh-llm-node, and mesh-llm-nodejs.
  • Standardized the native SDK library name on libmeshllm_ffi / meshllm_ffi.dll.
  • Adapted the embedded host runtime control path so SDK serving uses the real runtime controller rather than fake controllers or unsupported placeholders.
  • Added real Swift and Kotlin SDK wrappers, tests, generated binding flow, native runtime resolution, and runnable examples.
  • Added a real Node.js SDK for utility and Electron apps, including client mode, model management, inference, serving load/unload, TypeScript types, native addon loading, and Windows-aware packaging.
  • Added native runtime artifact packaging and verification for backend flavors such as CPU, Metal, CUDA, Vulkan, and ROCm.
  • Added crate packaging helpers so native SDK runtime crates can be published and resolved by consumers.
  • Converted docs/SDK.md from a planning/spec document into a usage guide for Swift, Kotlin, Node.js, native runtime artifacts, examples, lifecycle, errors, and platform support.
  • Added CI contract checks and SDK smoke/build paths across Rust, Swift, Kotlin, Node.js, and Windows-relevant paths.

User-facing SDK shape

The public SDK direction is now:

  • Node as the main entry point.
  • node.models for model search, metadata, installed models, downloads, and capabilities.
  • node.inference for chat/responses/list/cancel.
  • node.serving for status, load, and unload.
  • Native runtime artifacts are selected by platform/backend flavor and verified before loading.

Native runtime packaging

This PR keeps libllama backend variants behind MeshLLM native runtime artifacts rather than making SDK consumers link directly to backend-specific llama libraries. SDK consumers load MeshLLM through the canonical native runtime library, and runtime artifacts carry the backend flavor metadata and checksum.

Baseline artifact shapes include macOS Metal/CPU, Linux CPU/CUDA/Vulkan/ROCm, and Windows CPU/CUDA/Vulkan/ROCm.

Platform notes

  • Swift: macOS/iOS-family package surface with Catalyst example coverage.
  • Kotlin: JVM/Android package surface with generated bindings and runtime resolution.
  • Node.js: Node/Electron package surface with N-API addon loading for macOS, Linux, and Windows.
  • Windows: native runtime artifact naming and Node addon loading use the Windows DLL shape, with CI coverage added for the addon crate.

Compatibility

This is SDK and packaging work. It does not intentionally change the mesh wire protocol. The public SDK API is being established here, so downstream Swift/Kotlin/Node consumers should move to the new Node surfaces rather than the removed client wrapper names.

Validation

Local validation run across this branch included:

  • cargo check -p mesh-llm-nodejs
  • cargo test -p mesh-llm-nodejs
  • cargo fmt --all -- --check
  • node --test sdk/node/test/*.test.js
  • scripts/check-sdk-contract.sh
  • Node native addon load smoke
  • git diff --check

CI also has additional SDK contract, native runtime package, Swift/Kotlin smoke, Node addon, and Windows-relevant build paths so review catches drift before release.

@i386
i386 force-pushed the jd/sdk branch 3 times, most recently from fcca2c4 to d32ff07 Compare May 22, 2026 07:59
@i386 i386 changed the title Mesh SDK with serving and client modes Ship real client and serving SDKs across Swift, Kotlin, and Node.js May 22, 2026
@i386
i386 marked this pull request as ready for review May 22, 2026 23:23
@i386
i386 requested a review from ndizazzo May 22, 2026 23:23

@ndizazzo ndizazzo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing jumps out as wrong, but I had a thought about the boundary of how these SDKs are split...

I think it might be worth thinking about splitting the client and host surfaces at the crate level before the FFI stabilizes. This would allow apps to include smaller SDKs to either:

  1. get inference
  2. be inference

The Rust types already is almost there with the separation (MeshClient vs MeshNode) but RN they're in the same crate and compile to the same artifact.

A smaller mobile SDK consumer wouldn't need llama.cpp, skippy, or the runtime control surface. As it stands, now they'd ship all of it.

Something like:

  • mesh-llm-api-client: inference, discovery, catalog, identity, events. The dependency footprint stays light (QUIC, protobuf, HF download, catalog).
  • mesh-llm-api-node: re-exports client + adds ServingController, model loading, device policy, runtime plumbing.

At the FFI boundary you'd get two distinct handles (ClientHandle vs NodeHandle) which maps cleanly to Swift/Kotlin consumers. no runtime "serving_enabled" boolean that returns errors when you call something that shouldn't exist on a client. Better type safety, smaller binaries, and the UniFFI interface stays clean from the start instead of needing a breaking change later.

The Node.js addon could keep the same single entry point with its existing feature flag.

Comment thread crates/mesh-llm-nodejs/src/lib.rs Outdated
"modelRef": model.model_ref,
"modelId": model.model_id,
"instanceId": model.instance_id,
"state": format!("{:?}", model.state),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

format!("{:?}", ...) ties the JSON API contract to Rust's Debug trait output. For ServingModelState::Unknown("x"), Debug produces Unknown("x") while UniFFI's tagged enum encoding produces { "Unknown": "x" } — an inconsistency across SDK targets.

More generally, Debug is an unstable serialization contract. These should use explicit match arms or serde Serialize instead.

Comment thread crates/mesh-llm-nodejs/src/lib.rs Outdated
Comment on lines +607 to +610
"vision": format!("{:?}", value.vision),
"audio": format!("{:?}", value.audio),
"reasoning": format!("{:?}", value.reasoning),
"toolUse": format!("{:?}", value.tool_use),

@ndizazzo ndizazzo May 23, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Samesies

Comment on lines +1037 to +1047
fn set_device_policy<'a>(&'a self, policy: NodeDevicePolicy) -> ServingFuture<'a, ()> {
Box::pin(async move {
match policy {
NodeDevicePolicy::Auto => Ok(()),
policy => Err(anyhow::anyhow!(ServingError::UnsupportedDevicePolicy {
policy,
})),
}
})
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an SDK consumer uses the host runtime path (not EmbeddedServingController), passing DevicePolicy::Cpu or DevicePolicy::Gpu fails with an error.

The EmbeddedServingController accepts all policies. This means node.serving().set_device_policy(...) silently fails depending on which backend is active.

Needs at minimum a doc note or a consistent behavior path.

Failed();
Unloading();
Stopped();
Unknown(string value);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"state": format!("{:?}", model.state)
// Produces: "Unknown(\"some state\")" in Node.js

vs in the UDL:

[Enum]
interface ServingModelState {
    Unknown(string value);
};
// Produces: { "Unknown": "some state" } via UniFFI in Swift/Kotlin

A developer switching between Kotlin and Node.js gets fundamentally different representations for the same enum variant. Node.js should normalize to match: { "type": "Unknown", "value": "..." }

}

impl ServingController for EmbeddedServingController {
fn load<'a>(&'a self, request: LoadModelRequest) -> ServingFuture<'a, ServedModel> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The runtime path in api/mod.rs goes through reserve_runtime_capacity_for_model with a capacity ledger.

This calls SkippyModelHandle::load(options) unconditionally and relies on skippy to fail on OOM — producing native log errors rather than structured error messages.

Potentially consider at least a total_vram_bytes gate before loading.

Comment on lines +107 to +115
pub async fn model_list(&self) -> Vec<(String, String)> {
self.inner
.lock()
.await
.models
.values()
.map(|model| (model.served.model_id.clone(), model.served.model_id.clone()))
.collect()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both tuple fields use model_id. If the same model is loaded with two different instance_ids, model_list returns duplicates.

Downstream callers like FFI::inference_list_models use this directly.

Comment on lines 6083 to 6093
let outstanding_refs = std::sync::Arc::strong_count(&rt);
if outstanding_refs == 1 {
let dir = rt.dir().to_path_buf();
drop(rt);
let _ = std::fs::remove_dir_all(&dir);
} else {
tracing::warn!(
outstanding_refs,
"skipping runtime directory removal during shutdown because runtime references remain"
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arc::strong_count is a snapshot, not a guarantee. Between the count check and drop(rt), another thread could clone the Arc. Consider Arc::try_unwrap instead:

if let Ok(rt) = Arc::try_unwrap(rt) {
    let dir = rt.dir().to_path_buf();
    drop(rt);
    let _ = std::fs::remove_dir_all(&dir);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is WAY too long

Comment thread sdk/node/index.js
Comment on lines +34 to +39
function loadNativeFile(file) {
const resolved = path.resolve(file)
const mod = { exports: {} }
process.dlopen(mod, resolved)
return mod.exports
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bypasses Node.js module caching and require interception hooks. The addon can be loaded multiple times (no singleton cache) and require.resolve() won't work for diagnostics.

Worth a comment explaining why require() wasn't used in this case?

@michaelneale

Copy link
Copy Markdown
Collaborator

this is really cool - and I could use this too, so yeah lets get this landed. I would like to use it from other rust apps at build time.

@i386
i386 merged commit c36132f into main May 23, 2026
21 checks passed
@i386
i386 deleted the jd/sdk branch May 23, 2026 10:19
@i386

i386 commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

@ndizazzo incredible feedback. I have done all of what you suggested and merged!

michaelneale added a commit that referenced this pull request May 24, 2026
* origin/main:
  Ship real client and serving SDKs across Swift, Kotlin, and Node.js (#634)
  task: Add tok/s and model name to nightly workflow summary (#654)
  fix(docs): restore docs.anarchai.org as Pages custom domain
  Update llama.cpp upstream pin
  Fix skippy smoke llama build directory (#655)
  Update pinned llama.cpp revision (#646)
  Normalize non-stream chat tool call IDs
  chore(github): Add lightweight issue templates to repo
  Add nightly mesh stability harness (#631)
  fix(moa): show real tok/s on mesh by reporting effective completion_tokens (#638)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants