Skip to content

Commit 9119152

Browse files
fix: make aikit's native TTS/LLM/STT backends opt-in (-Dnative-ai=true)
Merging aikit into metanoia's root build.zig unconditionally meant a default `zig build`/`zig build test` now required qwentts.cpp/mlx-c/ whisper-cpp to be built/installed just to *compile* — breaking CI (.github/workflows/test.yml sets up none of this) and breaking a fresh `brew install zig gtk4 pango cairo glib sqlite3 && zig build run` for anyone who hasn't built the native-AI dependencies. Caught before pushing by actually running `zig build test` after the merge rather than assuming a clean merge meant a working build. tts_backend/llm_backend already defaulted to "remote" at the runtime config level, but that was meaningless if the app couldn't even compile without the native dependencies present — this adds the actual off switch: `-Dnative-ai` (default false). Off, `zig build`/`zig build test` work with nothing beyond the README's existing gtk4/sqlite3 requirement, same as before aikit existed. On (after building aikit's dependencies per aikit/README.md), the native backends link in as before. tts_client.zig / llm_client.zig: their native-backend sections (already self-contained, delineated blocks) are now wrapped in a comptime-selected namespace (real implementation when built with -Dnative-ai=true, a stub returning a clear error otherwise) rather than unconditionally importing "aikit" at file scope — same pattern aikit's own root.zig already uses for its macOS-only backends, just applied to a whole feature area instead of a single import. Verified: default build (no flag) compiles clean, full test suite passes (56/57, 1 pre-existing skip), real smoke run with no crashes. -Dnative-ai= true also still verified fully working (real TTS voice cloning, real LLM generation, both through zig build test-native-tts/test-native-llm) — temporarily symlinked the worktree's already-built vendor/ dependencies in for this checkout rather than rebuilding ~1.7GB twice; removed after verifying. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e0e0180 commit 9119152

3 files changed

Lines changed: 306 additions & 229 deletions

File tree

build.zig

Lines changed: 106 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,47 @@ pub fn build(b: *std.Build) void {
1717
// set a preferred release mode, allowing the user to decide how to optimize.
1818
const optimize = b.standardOptimizeOption(.{});
1919

20-
// Native TTS backend (aikit/qwentts.cpp). aikit's own build.zig defaults
21-
// its "qwen-build-dir" option to "../vendor/qwentts.cpp/build", a
22-
// `.cwd_relative` path meant to be resolved from *aikit's own* directory
23-
// (i.e. running `zig build` from within aikit/). Here we're pulling
24-
// aikit in as a dependency of metanoia's root build, so `zig build` is
25-
// invoked from the metanoia repo root instead — the same relative
26-
// default would resolve one directory too high. Override it to the
27-
// correct path from *this* root: vendor/qwentts.cpp/build (no "..").
28-
const aikit_dep = b.dependency("aikit", .{
29-
.target = target,
30-
.optimize = optimize,
31-
.@"qwen-build-dir" = @as([]const u8, "vendor/qwentts.cpp/build"),
32-
});
33-
const aikit_mod = aikit_dep.module("aikit");
34-
// It's also possible to define more custom flags to toggle optional features
35-
// of this build script using `b.option()`. All defined flags (including
36-
// target and optimize options) will be listed when running `zig build --help`
37-
// in this directory.
20+
// Native AI backends (aikit — TTS/LLM/STT) are opt-in, off by default.
21+
// aikit unconditionally links against qwentts.cpp (libqwen) for its TTS
22+
// backend, and on macOS also mlx-c and (if the STT capability is
23+
// reached) whisper.cpp — all real, heavy, locally-built/installed
24+
// native dependencies (see aikit/README.md) that a fresh checkout,
25+
// and CI (.github/workflows/test.yml), do NOT have. Every native
26+
// capability already defaults to "remote"/off at the *runtime config*
27+
// level (tts_backend/llm_backend default to "remote" — see
28+
// src/tts_client.zig / src/llm_client.zig), but that's meaningless if
29+
// the app can't even *compile* without those dependencies present.
30+
// This flag is the actual off switch: default false means `zig build`
31+
// / `zig build test` work with nothing beyond the README's normal
32+
// `brew install zig gtk4 pango cairo glib sqlite3`. Pass
33+
// `-Dnative-ai=true` (after building aikit's dependencies per
34+
// aikit/README.md) to link the real thing in.
35+
const native_ai = b.option(
36+
bool,
37+
"native-ai",
38+
"Link aikit's native TTS/LLM/STT backends (requires qwentts.cpp/mlx-c/whisper-cpp set up locally, see aikit/README.md). Off by default so a fresh checkout builds with nothing beyond gtk4/sqlite3.",
39+
) orelse false;
40+
41+
const native_ai_opts = b.addOptions();
42+
native_ai_opts.addOption(bool, "native_ai", native_ai);
43+
const build_options_mod = native_ai_opts.createModule();
44+
45+
// aikit's own build.zig defaults its "qwen-build-dir" option to
46+
// "../vendor/qwentts.cpp/build", a `.cwd_relative` path meant to be
47+
// resolved from *aikit's own* directory (i.e. running `zig build` from
48+
// within aikit/). Here we're pulling aikit in as a dependency of
49+
// metanoia's root build, so `zig build` is invoked from the metanoia
50+
// repo root instead — the same relative default would resolve one
51+
// directory too high. Override it to the correct path from *this*
52+
// root: vendor/qwentts.cpp/build (no "..").
53+
const aikit_mod: ?*std.Build.Module = if (native_ai) blk: {
54+
const aikit_dep = b.dependency("aikit", .{
55+
.target = target,
56+
.optimize = optimize,
57+
.@"qwen-build-dir" = @as([]const u8, "vendor/qwentts.cpp/build"),
58+
});
59+
break :blk aikit_dep.module("aikit");
60+
} else null;
3861

3962
// This creates a module, which represents a collection of source files alongside
4063
// some compilation options, such as optimization mode and linked system libraries.
@@ -43,6 +66,16 @@ pub fn build(b: *std.Build) void {
4366
// to our consumers. We must give it a name because a Zig package can expose
4467
// multiple modules and consumers will need to be able to specify which
4568
// module they want to access.
69+
// src/tts_client.zig / src/llm_client.zig (reachable from src/root.zig)
70+
// import "aikit" and "build_options" for the native backend switch —
71+
// "aikit" is only added when native_ai is true (see aikit_mod above);
72+
// both files comptime-gate their own `@import("aikit")` on
73+
// `build_options.native_ai`, so it's fine for that import to simply not
74+
// exist otherwise.
75+
var mod_imports = std.ArrayListUnmanaged(std.Build.Module.Import).empty;
76+
mod_imports.append(b.allocator, .{ .name = "build_options", .module = build_options_mod }) catch @panic("OOM");
77+
if (aikit_mod) |am| mod_imports.append(b.allocator, .{ .name = "aikit", .module = am }) catch @panic("OOM");
78+
4679
const mod = b.addModule("metanoia", .{
4780
// The root source file is the "entry point" of this module. Users of
4881
// this module will only be able to access public declarations contained
@@ -54,11 +87,7 @@ pub fn build(b: *std.Build) void {
5487
// Later on we'll use this module as the root module of a test executable
5588
// which requires us to specify a target.
5689
.target = target,
57-
// src/tts_client.zig (reachable from src/root.zig) imports "aikit"
58-
// for the native TTS backend.
59-
.imports = &.{
60-
.{ .name = "aikit", .module = aikit_mod },
61-
},
90+
.imports = mod_imports.items,
6291
});
6392

6493
// Kit module — reusable, decoupled UI/UX component library.
@@ -92,6 +121,16 @@ pub fn build(b: *std.Build) void {
92121
//
93122
// If neither case applies to you, feel free to delete the declaration you
94123
// don't need and to put everything under a single module.
124+
// src/main.zig also imports src/tts_client.zig directly (relative
125+
// import, separate from the "metanoia" module above), so it needs its
126+
// own "aikit"/"build_options" imports too — same conditional-inclusion
127+
// reasoning as mod_imports above.
128+
var exe_imports = std.ArrayListUnmanaged(std.Build.Module.Import).empty;
129+
exe_imports.append(b.allocator, .{ .name = "metanoia", .module = mod }) catch @panic("OOM");
130+
exe_imports.append(b.allocator, .{ .name = "kit", .module = kit_mod }) catch @panic("OOM");
131+
exe_imports.append(b.allocator, .{ .name = "build_options", .module = build_options_mod }) catch @panic("OOM");
132+
if (aikit_mod) |am| exe_imports.append(b.allocator, .{ .name = "aikit", .module = am }) catch @panic("OOM");
133+
95134
const exe = b.addExecutable(.{
96135
.name = "metanoia",
97136
.root_module = b.createModule(.{
@@ -107,19 +146,7 @@ pub fn build(b: *std.Build) void {
107146
.optimize = optimize,
108147
// List of modules available for import in source files part of the
109148
// root module.
110-
.imports = &.{
111-
// Here "metanoia" is the name you will use in your source code to
112-
// import this module (e.g. `@import("metanoia")`). The name is
113-
// repeated because you are allowed to rename your imports, which
114-
// can be extremely useful in case of collisions (which can happen
115-
// importing modules from different packages).
116-
.{ .name = "metanoia", .module = mod },
117-
.{ .name = "kit", .module = kit_mod },
118-
// src/main.zig also imports src/tts_client.zig directly
119-
// (relative import, separate from the "metanoia" module
120-
// above), so it needs its own "aikit" import too.
121-
.{ .name = "aikit", .module = aikit_mod },
122-
},
149+
.imports = exe_imports.items,
123150
}),
124151
});
125152
// GTK4 library name differs between platforms:
@@ -229,48 +256,50 @@ pub fn build(b: *std.Build) void {
229256
test_step.dependOn(&run_build_tests.step);
230257
test_step.dependOn(&run_exe_tests.step);
231258

232-
// Real end-to-end native-TTS test, separate from the default `test`
233-
// step: it needs the ~1.3GB GGUF weights under vendor/qwentts.cpp/models
234-
// (see src/native_tts_test.zig), which normal CI and most local
235-
// checkouts won't have. It self-skips (error.SkipZigTest) when the
236-
// weights aren't present, but keeping it out of the default `test` step
237-
// still avoids paying its (real model load + real synthesis) cost on
238-
// every routine `zig build test` for contributors who do have them.
239-
const native_tts_test_mod = b.createModule(.{
240-
.root_source_file = b.path("src/native_tts_test.zig"),
241-
.target = target,
242-
.optimize = optimize,
243-
.imports = &.{
244-
.{ .name = "aikit", .module = aikit_mod },
245-
},
246-
});
247-
const native_tts_test = b.addTest(.{ .root_module = native_tts_test_mod });
248-
native_tts_test.root_module.linkSystemLibrary(gtk_lib, .{});
249-
native_tts_test.root_module.linkSystemLibrary("sqlite3", .{});
250-
native_tts_test.root_module.link_libc = true;
251-
const run_native_tts_test = b.addRunArtifact(native_tts_test);
252-
const native_tts_test_step = b.step("test-native-tts", "Run the real native-TTS end-to-end test (needs local GGUF weights)");
253-
native_tts_test_step.dependOn(&run_native_tts_test.step);
259+
// Real end-to-end native-TTS/native-LLM tests — only meaningful (and
260+
// only buildable at all, since they need "aikit") when native_ai is
261+
// true. When it's not, `zig build test-native-tts`/`test-native-llm`
262+
// simply don't exist as steps ("no step named ..." is a clear enough
263+
// signal — pass -Dnative-ai=true to get them). Separate from the
264+
// default `test` step even when native_ai is true: they need real
265+
// multi-hundred-MB model weights under vendor/ (see
266+
// src/native_tts_test.zig / src/native_llm_test.zig for exact paths),
267+
// which most checkouts — even native-ai-enabled ones — won't have set
268+
// up; they self-skip via error.SkipZigTest when absent, but keeping
269+
// them out of the default `test` step avoids paying their real
270+
// model-load-and-generate cost on every routine `zig build test` for
271+
// contributors who do have the weights.
272+
if (native_ai) {
273+
var native_test_imports = std.ArrayListUnmanaged(std.Build.Module.Import).empty;
274+
native_test_imports.append(b.allocator, .{ .name = "aikit", .module = aikit_mod.? }) catch @panic("OOM");
275+
native_test_imports.append(b.allocator, .{ .name = "build_options", .module = build_options_mod }) catch @panic("OOM");
254276

255-
// Real end-to-end native-LLM test, same reasoning/pattern as
256-
// test-native-tts above: needs the ~265MB MLX checkpoint at
257-
// vendor/llm/qwen2.5-0.5b-instruct-4bit/ (see src/llm_client.zig's
258-
// native_model_dir), which normal CI and most local checkouts won't
259-
// have — kept out of the default `test` step, self-skips via
260-
// error.SkipZigTest when the weights aren't present.
261-
const native_llm_test_mod = b.createModule(.{
262-
.root_source_file = b.path("src/native_llm_test.zig"),
263-
.target = target,
264-
.optimize = optimize,
265-
.imports = &.{
266-
.{ .name = "aikit", .module = aikit_mod },
267-
},
268-
});
269-
const native_llm_test = b.addTest(.{ .root_module = native_llm_test_mod });
270-
native_llm_test.root_module.link_libc = true;
271-
const run_native_llm_test = b.addRunArtifact(native_llm_test);
272-
const native_llm_test_step = b.step("test-native-llm", "Run the real native-LLM end-to-end test (needs local MLX checkpoint)");
273-
native_llm_test_step.dependOn(&run_native_llm_test.step);
277+
const native_tts_test_mod = b.createModule(.{
278+
.root_source_file = b.path("src/native_tts_test.zig"),
279+
.target = target,
280+
.optimize = optimize,
281+
.imports = native_test_imports.items,
282+
});
283+
const native_tts_test = b.addTest(.{ .root_module = native_tts_test_mod });
284+
native_tts_test.root_module.linkSystemLibrary(gtk_lib, .{});
285+
native_tts_test.root_module.linkSystemLibrary("sqlite3", .{});
286+
native_tts_test.root_module.link_libc = true;
287+
const run_native_tts_test = b.addRunArtifact(native_tts_test);
288+
const native_tts_test_step = b.step("test-native-tts", "Run the real native-TTS end-to-end test (needs local GGUF weights)");
289+
native_tts_test_step.dependOn(&run_native_tts_test.step);
290+
291+
const native_llm_test_mod = b.createModule(.{
292+
.root_source_file = b.path("src/native_llm_test.zig"),
293+
.target = target,
294+
.optimize = optimize,
295+
.imports = native_test_imports.items,
296+
});
297+
const native_llm_test = b.addTest(.{ .root_module = native_llm_test_mod });
298+
native_llm_test.root_module.link_libc = true;
299+
const run_native_llm_test = b.addRunArtifact(native_llm_test);
300+
const native_llm_test_step = b.step("test-native-llm", "Run the real native-LLM end-to-end test (needs local MLX checkpoint)");
301+
native_llm_test_step.dependOn(&run_native_llm_test.step);
302+
}
274303

275304
// Just like flags, top level steps are also listed in the `--help` menu.
276305
//

src/llm_client.zig

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
const std = @import("std");
1313
const builtin = @import("builtin");
1414
const ollama = @import("ollama_client.zig");
15-
const aikit = @import("aikit");
15+
// Only importable when built with `-Dnative-ai=true` (see root build.zig)
16+
// — that flag exists so a default `zig build`/`zig build test` (CI
17+
// included) doesn't require mlx-c to be installed just to compile.
18+
// `shouldUseNativeLLMBackend` still defaults to "remote" either way.
19+
const build_options = @import("build_options");
20+
const aikit = if (build_options.native_ai) @import("aikit") else struct {};
1621

1722
/// Pure decision: does `cfg_llm_backend` (the raw `llm_backend` string from
1823
/// `data/config.json`, see `src/models/config.zig`) select the native
@@ -73,14 +78,11 @@ pub fn generate_response(allocator: std.mem.Allocator, io: std.Io, prompt: []con
7378
// own home.
7479
const native_model_dir = "vendor/llm/qwen2.5-0.5b-instruct-4bit";
7580

76-
/// aikit's `models.qwen2_mlx` (MLX backend) is macOS-only — see
77-
/// `aikit/src/backend/mlx.zig`'s comptime guard and `aikit/src/root.zig`'s
78-
/// `void` fallback on other platforms. Mirror that guard here so this file
79-
/// still compiles (native path simply reports "unsupported on this
80-
/// platform" at runtime) when metanoia itself is built for Linux/Windows,
81-
/// same spirit as `aikit/README.md`'s "Cross-platform GPU" section notes
82-
/// for TTS.
83-
const NativeLLM = if (builtin.os.tag == .macos) aikit.models.qwen2_mlx.Qwen2LLM else void;
81+
/// True only when built with `-Dnative-ai=true` AND on macOS (aikit's MLX
82+
/// backend is macOS-only — see `aikit/src/backend/mlx.zig`'s comptime
83+
/// guard and `aikit/src/root.zig`'s `void` fallback on other platforms).
84+
const native_llm_available = build_options.native_ai and builtin.os.tag == .macos;
85+
const NativeLLM = if (native_llm_available) aikit.models.qwen2_mlx.Qwen2LLM else void;
8486

8587
// Loaded lazily on first native-backend call and kept for the life of the
8688
// process (loading the checkpoint is the expensive part) — same pattern
@@ -94,15 +96,20 @@ var native_llm: ?NativeLLM = null;
9496
/// resources explicitly before exit rather than relying on the (never
9597
/// reached, for a long-running app) process-exit cleanup.
9698
pub fn shutdownNativeLLMBackendForTesting() void {
97-
if (comptime builtin.os.tag != .macos) return;
99+
if (comptime !native_llm_available) return;
98100
if (native_llm) |*model| {
99101
model.deinit();
100102
native_llm = null;
101103
}
102104
}
103105

104106
fn generateResponseNative(io: std.Io, allocator: std.mem.Allocator, prompt: []const u8) ![]const u8 {
105-
if (comptime builtin.os.tag != .macos) {
107+
if (comptime !native_llm_available) {
108+
// Not built with -Dnative-ai=true (or not macOS): shouldUseNativeLLMBackend
109+
// still defaults to "remote", so this is only reached if someone
110+
// explicitly set `llm_backend: "native"` without also rebuilding
111+
// with the flag on a supported platform — a clear error beats a
112+
// missing-symbol build failure they'd otherwise never see coming.
106113
return error.NativeLLMUnsupportedPlatform;
107114
}
108115

0 commit comments

Comments
 (0)