Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
24 changes: 23 additions & 1 deletion .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -273,11 +273,26 @@ jobs:
shell: bash
run: ${{ matrix.settings.strip }}

# Stage the binary plus, for the apple-fm arm64 build, the sidecar
# libFoundationModels.dylib that build.rs places next to it. The dylib is
# dlopen'd at runtime (never linked), so it must travel inside the npm
# package alongside tokscale. Other targets stage only the binary.
- name: Stage release artifact
shell: bash
run: |
mkdir -p dist
cp "target/${{ matrix.settings.target }}/release/${{ matrix.settings.bin_name }}" dist/
if [ -f "target/${{ matrix.settings.target }}/release/libFoundationModels.dylib" ]; then
cp "target/${{ matrix.settings.target }}/release/libFoundationModels.dylib" dist/
echo "staged sidecar libFoundationModels.dylib"
fi
ls -la dist

- name: Upload CLI binary artifact
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.settings.artifact_name }}
path: target/${{ matrix.settings.target }}/release/${{ matrix.settings.bin_name }}
path: dist
if-no-files-found: error

prepare-release-provenance:
Expand Down Expand Up @@ -402,6 +417,13 @@ jobs:
if [ "${{ matrix.settings.binary_name }}" = "tokscale" ]; then
chmod +x "packages/${{ matrix.settings.package_dir }}/bin/tokscale"
fi
# Ship the apple-fm sidecar dylib next to the binary when present (only
# the arm64-darwin artifact carries it). tokscale dlopen's it at runtime
# from its own directory; "files": ["bin"] already includes it.
if [ -f "artifacts/${{ matrix.settings.package_dir }}/libFoundationModels.dylib" ]; then
cp "artifacts/${{ matrix.settings.package_dir }}/libFoundationModels.dylib" "packages/${{ matrix.settings.package_dir }}/bin/libFoundationModels.dylib"
echo "shipped sidecar libFoundationModels.dylib"
fi
ls -la "packages/${{ matrix.settings.package_dir }}/bin"

- name: Publish platform package
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ LLM 要約は**デフォルトで有効**になっています(`--no-summarize

| バックエンド | コマンド | 備考 |
|---------|---------|-------|
| `apple-fm` | (デフォルト) | ネイティブ Rust FFI 経由でオンデバイスの Apple Foundation Models を使用します(Python 不要)。`apple-fm` Cargo フィーチャーを有効にした macOS ビルドと、Apple Intelligence が有効な環境が必要です。それ以外の場合は組み込みの Rust ヒューリスティック分類器に透過的にフォールバックするため、デフォルトはどこでも動作します。 |
| `apple-fm` | (デフォルト) | ネイティブ Rust FFI 経由のオンデバイス Apple Foundation Models(Python 不要)。ビルド済みの Apple Silicon(macOS arm64)バイナリで有効化されており、Apple Intelligence を有効にした macOS 26 以降で動作します。それ以外(Intel Mac、それ以前の macOS、Linux、Windows)では組み込みの Rust ヒューリスティックに透過的にフォールバックするため、デフォルトはすべてのプラットフォームで動作します。 |
| `claude` | `claude -p` | Claude Code CLI がインストールされ認証済みである必要があります。 |
| `codex` | `codex --quiet` | Codex CLI がインストールされ認証済みである必要があります。 |
| `gemini` | `gemini -p` | Gemini CLI がインストールされ認証済みである必要があります。 |
Expand Down
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ tokscale report --workspace my-project --client opencode

| 백엔드 | 명령어 | 비고 |
|---------|---------|-------|
| `apple-fm` | (기본값) | 네이티브 Rust FFI를 통해 Apple Foundation Models를 온디바이스에서 사용 (Python 불필요). `apple-fm` Cargo 피처를 활성화한 macOS 빌드와 Apple Intelligence가 필요하며, 그렇지 않은 경우 내장 Rust 휴리스틱 분류기로 투명하게 폴백합니다 (기본값은 어디서나 동작). |
| `apple-fm` | (기본값) | 네이티브 Rust FFI를 통한 온디바이스 Apple Foundation Models (Python 불필요). 사전 빌드된 Apple Silicon(macOS arm64) 바이너리에 기본 포함되어 있으며, Apple Intelligence가 켜진 macOS 26 이상에서 동작합니다. 그 외 환경(Intel Mac, 이전 macOS, Linux, Windows)에서는 내장 Rust 휴리스틱으로 투명하게 폴백하므로 기본값은 모든 플랫폼에서 동작합니다. |
| `claude` | `claude -p` | Claude Code CLI가 설치되어 인증되어 있어야 함. |
| `codex` | `codex --quiet` | Codex CLI가 설치되어 인증되어 있어야 함. |
| `gemini` | `gemini -p` | Gemini CLI가 설치되어 인증되어 있어야 함. |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ tokscale report --workspace my-project --client opencode

| Backend | Command | Notes |
|---------|---------|-------|
| `apple-fm` | (default) | Uses Apple Foundation Models on-device via native Rust FFI (no Python). Requires a macOS build with the `apple-fm` Cargo feature and Apple Intelligence enabled; otherwise it transparently falls back to a built-in Rust heuristic classifier (so the default works everywhere). |
| `apple-fm` | (default) | On-device Apple Foundation Models via native Rust FFI (no Python). Enabled in the prebuilt Apple Silicon (macOS arm64) binary; runs on macOS 26+ with Apple Intelligence on, and transparently falls back to a built-in Rust heuristic everywhere else (Intel Macs, older macOS, Linux, Windows) — so the default works on every platform. |
| `claude` | `claude -p` | Requires Claude Code CLI installed and authenticated. |
| `codex` | `codex --quiet` | Requires Codex CLI installed and authenticated. |
| `gemini` | `gemini -p` | Requires Gemini CLI installed and authenticated. |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ tokscale report --workspace my-project --client opencode

| 后端 | 命令 | 说明 |
|---------|---------|-------|
| `apple-fm` | (默认) | 通过原生 Rust FFI 在本地使用 Apple Foundation Models(无需 Python)。需要启用 `apple-fm` Cargo 特性的 macOS 构建,且须开启 Apple Intelligence;否则将透明回退至内置 Rust 启发式分类器(因此默认配置可在所有平台正常使用)。 |
| `apple-fm` | (默认) | 通过原生 Rust FFI 在本地使用 Apple Foundation Models(无需 Python)。已在预构建的 Apple Silicon(macOS arm64)二进制中启用,在开启 Apple Intelligence 的 macOS 26 及以上系统运行;在其他环境(Intel Mac、更旧的 macOS、Linux、Windows)则透明回退至内置 Rust 启发式分类器,因此默认配置可在所有平台正常使用。 |
| `claude` | `claude -p` | 需要已安装并已认证的 Claude Code CLI。 |
| `codex` | `codex --quiet` | 需要已安装并已认证的 Codex CLI。 |
| `gemini` | `gemini -p` | 需要已安装并已认证的 Gemini CLI。 |
Expand Down
88 changes: 61 additions & 27 deletions crates/tokscale-cli/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,28 @@
//!
//! When (and only when) the optional `apple-fm` feature is enabled AND the
//! target OS is macOS, this builds the vendored `foundation-models-c` SwiftPM
//! package and links the resulting `libFoundationModels.dylib`.
//! package as a DYNAMIC `libFoundationModels.dylib` and stages it next to the
//! final binary.
//!
//! The dylib is deliberately NOT linked into `tokscale`. Apple's
//! `FoundationModels.framework` only exists on macOS 26+, and the Swift runtime
//! the dylib pulls in (e.g. `libswiftSynchronization`, macOS 15+) does too;
//! hard-linking any of them would make the *whole* CLI fail to `dyld`-load on
//! older macOS — a crash-on-launch for every command, not a feature fallback.
//! Worse, `import FoundationModels` autolinks the framework as a NON-weak load
//! command, so a `-weak_framework` flag can't reliably flip it.
//!
//! Instead the binary links nothing FM/Swift (verifiable: `otool -L tokscale`
//! shows no FoundationModels and no libswift*), and the `apple-fm` code path
//! `dlopen`s this dylib lazily at runtime — only on macOS 26+, where all its
//! dependencies are present. On older macOS the `dlopen` simply fails and the
//! caller degrades to the cross-platform Rust heuristic. This keeps a SINGLE
//! arm64 binary safe to ship to every Apple Silicon Mac via npm.
//!
//! When the feature is off, or the target is not macOS, this build script is a
//! complete no-op so that cross-platform / default builds are unaffected.

use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;

fn main() {
Expand Down Expand Up @@ -51,14 +67,17 @@ fn build_apple_fm() {
pkg_dir.join("Sources").display()
);

// Build the SwiftPM package in release mode.
// Build the DYNAMIC `FoundationModels` product (`libFoundationModels.dylib`)
// in release mode. We do not build/link the static archive: the dylib is
// loaded at runtime via `dlopen`, so nothing FM/Swift ends up in the
// tokscale binary's load commands.
let status = Command::new("swift")
.args([
"build",
"-c",
"release",
"--product",
"FoundationModelsStatic",
"FoundationModels",
"--package-path",
])
.arg(&pkg_dir)
Expand All @@ -78,38 +97,53 @@ fn build_apple_fm() {
);
}

// Copy the STATIC archive into OUT_DIR and link it statically, so the final
// tokscale binary is self-contained — no `libFoundationModels.dylib` to ship
// alongside it. The archive's only remaining dependencies are Apple's system
// FoundationModels framework and the OS Swift runtime, both always present on
// macOS 26 (verified with `otool -L`: no non-system dylib references).
let lib_name = "libFoundationModelsStatic.a";
let lib_name = "libFoundationModels.dylib";
let built_lib = pkg_dir.join(".build/release").join(lib_name);
if !built_lib.exists() {
panic!(
"apple-fm: swift build succeeded but {} was not found",
built_lib.display()
);
}
let dest_lib = Path::new(&out_dir).join(lib_name);
std::fs::copy(&built_lib, &dest_lib).unwrap_or_else(|e| {

// 1) Copy into OUT_DIR and bake its absolute path into the binary as a
// fallback. This is what `cargo test` / `cargo run` from arbitrary CWDs
// resolve to (the test harness binary lives in target/<profile>/deps, so
// a sibling-of-exe copy alone would not be found there).
let out_lib = Path::new(&out_dir).join(lib_name);
copy(&built_lib, &out_lib);
println!("cargo:rustc-env=TOKSCALE_FM_DYLIB={}", out_lib.display());

// 2) Stage a copy NEXT TO the final binary, so the primary runtime lookup
// (`current_exe()`'s directory) succeeds for both `cargo run` and the
// shipped npm package, where the dylib travels alongside `tokscale`.
//
// OUT_DIR is `.../target/<triple?>/<profile>/build/<crate>-<hash>/out`;
// ascending three parents lands on the profile dir that holds the binary.
if let Some(profile_dir) = profile_dir_from_out(&out_dir) {
let staged = profile_dir.join(lib_name);
copy(&built_lib, &staged);
// CI's release step copies this sibling dylib into the npm package's
// bin/ next to tokscale; surface its path for that step / debugging.
println!("cargo:warning=apple-fm: staged {}", staged.display());
}
}

/// `.../<profile>/build/<crate>-<hash>/out` -> `.../<profile>`.
fn profile_dir_from_out(out_dir: &str) -> Option<PathBuf> {
Path::new(out_dir)
.parent() // <crate>-<hash>
.and_then(Path::parent) // build
.and_then(Path::parent) // <profile>
.map(Path::to_path_buf)
}

fn copy(from: &Path, to: &Path) {
std::fs::copy(from, to).unwrap_or_else(|e| {
panic!(
"apple-fm: failed to copy {} -> {}: {e}",
built_lib.display(),
dest_lib.display()
from.display(),
to.display()
)
});

// Statically link the bindings archive, plus the system FoundationModels
// framework and the OS Swift runtime search path. The archive also carries
// autolink hints, but these are made explicit for a deterministic link.
println!("cargo:rustc-link-search=native={out_dir}");
println!("cargo:rustc-link-lib=static=FoundationModelsStatic");
println!("cargo:rustc-link-lib=framework=FoundationModels");
println!("cargo:rustc-link-search=native=/usr/lib/swift");
// The Swift runtime dylibs (e.g. libswift_Concurrency.dylib) are referenced
// via `@rpath`. They live in /usr/lib/swift, which is part of every macOS 26
// install's dyld shared cache, so baking this system rpath keeps the binary
// self-contained (it needs only OS-provided libraries at runtime).
println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift");
}
Loading
Loading