Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
/example-data
**.DS_Store
**.env

# SwiftPM scratch/index dirs (editor indexing creates these; cargo builds use target/)
.build/
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ members = [
"providers/mistralrs",
"providers/openrouter",
"providers/router",
"providers/applefm",
]

[workspace.package]
Expand Down Expand Up @@ -73,6 +74,7 @@ chat-deepseek = { path = "providers/deepseek", version = "0.1.3", optional =
chat-mistralrs = { path = "providers/mistralrs", version = "0.1.5", optional = true }
chat-openrouter = { path = "providers/openrouter", version = "0.1.0", optional = true }
chat-router = { path = "providers/router", version = "0.2.4", optional = true }
chat-applefm = { path = "providers/applefm", version = "0.1.0", optional = true }
tokio.workspace = true
schemars.workspace = true
serde.workspace = true
Expand Down Expand Up @@ -115,6 +117,7 @@ deepseek = ["dep:chat-deepseek", "dep:chat-completions"]
mistralrs = ["dep:chat-mistralrs"]
openrouter = ["dep:chat-openrouter"]
router = ["dep:chat-router"]
applefm = ["dep:chat-applefm"]
python = ["tools-rs/python"]
stream = ["chat-core/stream", "chat-completions?/stream", "chat-responses?/stream", "chat-gemini?/stream", "chat-openai?/stream", "chat-claude?/stream", "chat-ollama?/stream", "chat-huggingface?/stream", "chat-cerebras?/stream", "chat-deepseek?/stream", "chat-mistralrs?/stream", "chat-openrouter?/stream", "chat-router?/stream"]
Comment thread
EggerMarc marked this conversation as resolved.
Outdated
tungstenite = ["chat-core/tungstenite"]
Expand Down Expand Up @@ -341,6 +344,11 @@ name = "openai-image-generation"
path = "./examples/openai/image_generation.rs"
required-features = ["openai"]

[[example]]
name = "applefm-availability"
path = "./examples/applefm/availability.rs"
required-features = ["applefm"]

[[example]]
name = "router-embeddings"
path = "./examples/router/embeddings.rs"
Expand Down
21 changes: 21 additions & 0 deletions examples/applefm/availability.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! Probe whether this machine can run the Apple on-device foundation model.
//!
//! Run with:
//! ```sh
//! cargo run --example applefm-availability --features applefm
//! ```
//!
//! Exits 0 when the model is usable, 1 otherwise (with the reason).

fn main() {
let probe = chat_rs::applefm::availability();
if probe.available {
println!("Apple on-device model: AVAILABLE");
} else {
println!(
"Apple on-device model: UNAVAILABLE — {}",
probe.reason.as_deref().unwrap_or("no reason given")
);
std::process::exit(1);
}
}
13 changes: 13 additions & 0 deletions providers/applefm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "chat-applefm"
version = "0.1.0"
description = "Apple on-device foundation model provider for chat-rs, built on the FoundationModels framework."
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true

[dependencies]
serde.workspace = true
serde_json.workspace = true
Comment thread
EggerMarc marked this conversation as resolved.
Outdated
17 changes: 17 additions & 0 deletions providers/applefm/bridge/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "AppleFMBridge",
// Minimum macOS 15 so host binaries still run on machines without the
// FoundationModels framework; every use of the framework is guarded
// with `#available(macOS 26.0, *)` and the framework is weak-linked
// by the crate's build script.
platforms: [.macOS(.v15)],
products: [
.library(name: "AppleFMBridge", type: .static, targets: ["AppleFMBridge"])
],
targets: [
.target(name: "AppleFMBridge")
]
)
61 changes: 61 additions & 0 deletions providers/applefm/bridge/Sources/AppleFMBridge/Exports.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// The C surface of the bridge.
//
// This file is the only place where Rust and Swift meet: each
// `@_cdecl` function below is callable as a plain C function from the
// crate's `src/ffi.rs`. Payloads cross the boundary as JSON strings —
// the same idea as an HTTP provider's wire format, minus the network.
//
// Memory contract: every `char*` returned here is `strdup`-allocated
// and must be released by the caller via `afm_string_free`.

import Foundation
#if canImport(FoundationModels)
import FoundationModels
#endif

private func cString(_ s: String) -> UnsafeMutablePointer<CChar>? {
strdup(s)
}

private func availabilityJSON() -> String {
#if canImport(FoundationModels)
if #available(macOS 26.0, *) {
switch SystemLanguageModel.default.availability {
case .available:
return #"{"available":true}"#
case .unavailable(let reason):
let why: String
switch reason {
case .deviceNotEligible:
why = "this device is not eligible for Apple Intelligence"
case .appleIntelligenceNotEnabled:
why = "Apple Intelligence is not enabled in System Settings"
case .modelNotReady:
why = "model assets are not downloaded yet; retry once Apple Intelligence finishes setting up"
@unknown default:
why = "the model is unavailable for an unknown reason"
}
return #"{"available":false,"reason":"\#(why)"}"#
@unknown default:
return #"{"available":false,"reason":"unrecognized availability state"}"#
}
} else {
return #"{"available":false,"reason":"macOS 26 or later is required"}"#
}
#else
return #"{"available":false,"reason":"built against an SDK without the FoundationModels framework"}"#
#endif
}

/// Probe whether the on-device Apple foundation model can be used.
/// Returns `{"available": bool, "reason"?: string}`.
@_cdecl("afm_availability")
public func afm_availability() -> UnsafeMutablePointer<CChar>? {
cString(availabilityJSON())
}

/// Release a string previously returned by this bridge.
@_cdecl("afm_string_free")
public func afm_string_free(_ ptr: UnsafeMutablePointer<CChar>?) {
free(ptr)
}
74 changes: 74 additions & 0 deletions providers/applefm/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! Compiles the Swift bridge (`bridge/`) and links it into the crate.
//!
//! The bridge is the only piece of this provider that talks to Apple's
//! FoundationModels framework — see `bridge/Sources/AppleFMBridge`. It is
//! built with SwiftPM into a static library and linked here, so users only
//! ever run `cargo build`.
//!
//! On non-macOS targets (or when `APPLEFM_SKIP_BRIDGE` is set, or on
//! docs.rs) the bridge is not built and the crate falls back to a stub
//! that reports the model as unavailable. This keeps the workspace
//! compiling everywhere without Xcode.

use std::env;
use std::path::PathBuf;
use std::process::Command;

fn main() {
println!("cargo:rustc-check-cfg=cfg(applefm_bridge)");
println!("cargo:rerun-if-changed=bridge/Sources");
println!("cargo:rerun-if-changed=bridge/Package.swift");
println!("cargo:rerun-if-env-changed=APPLEFM_SKIP_BRIDGE");

let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let docsrs = env::var("DOCS_RS").is_ok();
let skip = env::var("APPLEFM_SKIP_BRIDGE").is_ok();
if target_os != "macos" || docsrs || skip {
// Stub mode: `cfg(applefm_bridge)` is not emitted.
return;
}

let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let scratch = out_dir.join("bridge-build");

let status = Command::new("swift")
.arg("build")
.args(["-c", "release"])
.arg("--package-path")
.arg(manifest_dir.join("bridge"))
.arg("--scratch-path")
.arg(&scratch)
.status()
.expect(
"failed to invoke `swift build` — building chat-applefm on macOS \
requires the Xcode command line tools (xcode-select --install)",
);
assert!(
status.success(),
"swift build of the AppleFMBridge package failed"
);

println!(
"cargo:rustc-link-search=native={}",
scratch.join("release").display()
);
println!("cargo:rustc-link-lib=static=AppleFMBridge");

// Swift runtime stubs from the SDK so the autolinked -lswiftCore etc.
// resolve at link time. At run time the OS runtime in /usr/lib/swift
// is used.
let sdk_path = Command::new("xcrun")
.args(["--show-sdk-path"])
.output()
.expect("failed to invoke `xcrun --show-sdk-path`");
let sdk_path = String::from_utf8_lossy(&sdk_path.stdout).trim().to_string();
println!("cargo:rustc-link-search=native={sdk_path}/usr/lib/swift");
println!("cargo:rustc-link-search=native=/usr/lib/swift");

// Weak-link so binaries still load on macOS < 26; the bridge guards
// every use behind `#available` and reports unavailability instead.
println!("cargo:rustc-link-arg=-Wl,-weak_framework,FoundationModels");

println!("cargo:rustc-cfg=applefm_bridge");
}
43 changes: 43 additions & 0 deletions providers/applefm/src/ffi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! Safe wrappers over the bridge's C surface.
//!
//! When the crate is built without the bridge (non-macOS target,
//! docs.rs, or `APPLEFM_SKIP_BRIDGE=1`), the stub module below stands in
//! and reports the model as unavailable — same shape, no linking.

#[cfg(applefm_bridge)]
mod real {
use std::ffi::{CStr, c_char};

unsafe extern "C" {
fn afm_availability() -> *mut c_char;
fn afm_string_free(ptr: *mut c_char);
}

pub fn availability_json() -> String {
// SAFETY: the bridge returns a NUL-terminated string allocated
// with `strdup`; we copy it out and hand it straight back to
// `afm_string_free`. A null return is mapped to an error value.
unsafe {
let ptr = afm_availability();
if ptr.is_null() {
return r#"{"available":false,"reason":"bridge returned null"}"#.to_owned();
}
let json = CStr::from_ptr(ptr).to_string_lossy().into_owned();
afm_string_free(ptr);
json
}
}
}

#[cfg(not(applefm_bridge))]
mod stub {
pub fn availability_json() -> String {
r#"{"available":false,"reason":"chat-applefm was built without the Swift bridge (non-macOS target, docs build, or APPLEFM_SKIP_BRIDGE set)"}"#
.to_owned()
}
}

#[cfg(applefm_bridge)]
pub(crate) use real::availability_json;
#[cfg(not(applefm_bridge))]
pub(crate) use stub::availability_json;
59 changes: 59 additions & 0 deletions providers/applefm/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! Apple on-device foundation model provider for chat-rs.
//!
//! Talks to the ~3B-parameter model that ships with Apple Intelligence
//! (macOS 26+) through Apple's FoundationModels framework. There is no
//! HTTP and no weights file: the OS owns the model; this crate owns the
//! translation.
//!
//! ## How it connects
//!
//! Rust cannot call Swift directly, so the crate embeds a small Swift
//! package (`bridge/`) exposing a few plain C functions. Requests and
//! responses cross that boundary as JSON strings — the same mental model
//! as an HTTP provider's wire format, minus the network. The bridge is
//! compiled automatically by `build.rs`; users only run `cargo build`.
//!
//! On non-macOS targets the crate still compiles (with a stub bridge)
//! and reports the model as unavailable.
//!
//! ## Current scope
//!
//! This is the first slice: probing model availability.
//!
//! ```no_run
//! let probe = chat_applefm::availability();
//! if probe.available {
//! println!("on-device model ready");
//! } else {
//! println!("unavailable: {}", probe.reason.as_deref().unwrap_or("?"));
//! }
//! ```

mod ffi;

use serde::Deserialize;

/// Result of probing whether the on-device Apple model is usable here.
#[derive(Debug, Clone, Deserialize)]
pub struct Availability {
/// Whether a session can be created on this machine right now.
pub available: bool,
/// Human-readable explanation when `available` is `false`
/// (ineligible hardware, Apple Intelligence disabled, assets still
/// downloading, OS too old, or a bridge-less build).
#[serde(default)]
pub reason: Option<String>,
}

/// Ask the OS whether the Apple Intelligence on-device model can be used.
///
/// Cheap to call; performs no generation. This is the recommended first
/// call before constructing a client, and what router strategies should
/// consult when deciding whether this provider is eligible at all.
pub fn availability() -> Availability {
let json = ffi::availability_json();
serde_json::from_str(&json).unwrap_or_else(|_| Availability {
available: false,
reason: Some(format!("malformed bridge reply: {json}")),
})
}
8 changes: 8 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ pub mod router {
pub use chat_router::*;
}

#[cfg(feature = "applefm")]
pub mod applefm {
pub use chat_applefm::*;
}

pub mod prelude {
pub use crate::ChatOptions;
pub use crate::Messages;
Expand Down Expand Up @@ -139,4 +144,7 @@ pub mod prelude {

#[cfg(feature = "router")]
pub use crate::router;

#[cfg(feature = "applefm")]
pub use crate::applefm;
}
Loading