cactus_sys/needle.rs
1//! Raw declarations for the Needle 3 engine C API (`include/needle.h`).
2//!
3//! ## Overview
4//!
5//! Nine functions, no handles, no opaque types. The engine owns one process-global model per
6//! kind: a text model ([`NEEDLE_TEXT`], Needle 3) and a speech model ([`NEEDLE_SPEECH`],
7//! Whistle). [`needle_load`] reads whichever kind an archive holds and keeps the other, so both
8//! can live in one process; [`needle_models`] reports which are loaded.
9//!
10//! The text model is configured with [`needle_init`], answers with [`needle_complete`] and is
11//! rewound by [`needle_reset`]. The speech model transcribes with [`needle_transcribe`], and also
12//! runs inside [`needle_complete`] when it is handed a clip instead of a string, configured by
13//! [`needle_set_audio`]. [`needle_embed`] embeds either kind of input.
14//!
15//! The declarations are written by hand rather than generated. Nine stable entry points do not
16//! justify a `bindgen` build dependency, and hand-written signatures let every function carry the
17//! `# Safety` contract that was measured against the shipped archive.
18//!
19//! Every function that can fail returns a negative value when it does, and [`needle_last_error`]
20//! then names the reason. That string is one process-global slot shared by both kinds, owned by
21//! the runtime and overwritten by the next call: copy it before calling anything else.
22//!
23//! Audio is 16 kHz mono `f32` PCM in `[-1, 1]`, at most 30 seconds (480 000 samples) per call.
24//!
25//! ## Usage
26//!
27//! ```no_run
28//! use core::ffi::{c_char, c_int, c_ulonglong};
29//!
30//! use cactus_sys::{NEEDLE_TEXT, needle_complete, needle_init, needle_load, needle_models};
31//!
32//! let weights: &[u8] = b"...contents of needle3.cact...";
33//! let mut out = [0u8; 64 * 1024];
34//!
35//! // SAFETY: every call below happens on one thread, in order, and the engine copies the
36//! // archive during `needle_load`, so `weights` needs no particular lifetime afterwards.
37//! unsafe {
38//! assert_eq!(needle_load(weights.as_ptr(), weights.len() as c_ulonglong), 0);
39//! assert_ne!(needle_models() & NEEDLE_TEXT, 0);
40//! assert!(needle_init(c"You are terse.".as_ptr(), c"[]".as_ptr(), core::ptr::null()) > 0);
41//!
42//! let tokens = needle_complete(
43//! c"hello".as_ptr(),
44//! core::ptr::null(),
45//! 0,
46//! 64,
47//! out.as_mut_ptr().cast::<c_char>(),
48//! out.len() as c_int,
49//! );
50//! assert!(tokens >= 0);
51//! }
52//! ```
53
54use core::ffi::{c_char, c_float, c_int, c_uchar, c_ulonglong};
55
56/// Commit of the `Cactus-Compute/needle3` Hugging Face repository this crate is pinned to.
57///
58/// It is the `commit` field of `cactus-sys/prebuilt.toml`, the same revision the
59/// `download-binaries` feature fetches archives from, threaded through by the build script so
60/// that the pin has exactly one source of truth.
61///
62/// # Examples
63///
64/// ```
65/// assert_eq!(cactus_sys::NEEDLE_ENGINE_COMMIT.len(), 40);
66/// ```
67pub const NEEDLE_ENGINE_COMMIT: &str = env!("NEEDLE_ENGINE_COMMIT");
68
69/// Bit set in [`needle_models`] when a text model (Needle 3) is loaded.
70pub const NEEDLE_TEXT: c_int = 1;
71
72/// Bit set in [`needle_models`] when a speech model (Whistle) is loaded.
73pub const NEEDLE_SPEECH: c_int = 2;
74
75unsafe extern "C" {
76 /// Configures the conversation prefix: system prompt, tool declarations and tool index.
77 ///
78 /// Returns the number of tokens in the resulting prefix, always greater than zero, or a
79 /// negative value when it fails, most commonly because no text model has been loaded yet;
80 /// [`needle_last_error`] then names the reason. Calling it again replaces the previous
81 /// configuration.
82 ///
83 /// Tool declarations are flat objects (`{name, description, parameters, triggers?}`), not the
84 /// OpenAI-style nested form. **The engine does not validate `tools_json`**: malformed JSON
85 /// still returns a success value, and the damage only shows up later as a model that ignores
86 /// the tools. Validate the document before handing it over.
87 ///
88 /// # Safety
89 ///
90 /// - `system_prompt`, `tools_json` and `tool_index_path` must each be either null or a
91 /// pointer to a NUL-terminated C string that stays valid for the duration of the call.
92 /// `system_prompt` and `tool_index_path` are documented as optional; passing null for
93 /// `tools_json` declares no tools.
94 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model. No
95 /// call in this module may run concurrently with any other; the caller must serialise
96 /// all of them.
97 /// - A text model must have been loaded with [`needle_load`] first, otherwise this call
98 /// fails. Loading a text archive again, even the same one, discards the configuration:
99 /// call this again afterwards.
100 pub fn needle_init(
101 system_prompt: *const c_char,
102 tools_json: *const c_char,
103 tool_index_path: *const c_char,
104 ) -> c_int;
105
106 /// Runs one completion on text or on a speech clip and writes a JSON envelope into `out`.
107 ///
108 /// Exactly one of `input` and `pcm` is non-null. Given `pcm`, the engine transcribes the
109 /// clip with the speech model, as configured by [`needle_set_audio`], answers the
110 /// transcript with the text model, and merges the speech fields into the same JSON object
111 /// under an `audio_` prefix (`audio_text`, `audio_language`, ...).
112 ///
113 /// Returns a non-negative **token count** on success (not the number of bytes written) and
114 /// a negative value on failure, with the reason in [`needle_last_error`]. On failure `out`
115 /// also holds a NUL-terminated error envelope carrying the same text, such as
116 /// `{"type":"error","error":"needle_init not called"}`. A failed audio completion may still
117 /// carry the `audio_*` fields of a transcription that ran before the failure.
118 ///
119 /// `out` is always NUL-terminated, and the envelope is **silently truncated** to
120 /// `out_capacity - 1` bytes when it does not fit: there is no error and no length hint, so a
121 /// short buffer surfaces as JSON that fails to parse. An `out_capacity` of `0` is tolerated
122 /// and writes nothing. Size the buffer generously; 64 KiB is comfortable for tool calls, and
123 /// an audio completion with word timestamps needs room for every word on top.
124 ///
125 /// # Safety
126 ///
127 /// - Exactly one of `input` and `pcm` must be non-null.
128 /// - `input` must be null or a pointer to a NUL-terminated C string valid for the call.
129 /// - `pcm` must be null, or valid for reads of `samples` `f32` values for the duration of
130 /// the call, holding 16 kHz mono audio in `[-1, 1]`. `samples` must not be negative and
131 /// must not exceed 480 000 (30 seconds); with `pcm` null it is ignored and `0` is
132 /// conventional.
133 /// - `out` must be valid for writes of `out_capacity` bytes, and `out_capacity` must not be
134 /// negative or larger than the allocation behind `out`.
135 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model; the
136 /// caller must serialise every call in this module.
137 /// - A text model must be loaded and [`needle_init`] must have succeeded first, and a
138 /// speech model must also be loaded when `pcm` is non-null; otherwise this fails.
139 pub fn needle_complete(
140 input: *const c_char,
141 pcm: *const c_float,
142 samples: c_int,
143 max_new_tokens: c_int,
144 out: *mut c_char,
145 out_capacity: c_int,
146 ) -> c_int;
147
148 /// Configures the transcription that [`needle_complete`] runs for itself on a clip.
149 ///
150 /// `language`, `keywords` and `word_timestamps` mean what they mean in
151 /// [`needle_transcribe`]. The setting is process-global and stays in force until the next
152 /// call; the defaults are to detect the language, use no keywords and report no word times.
153 /// The engine **copies** both strings, so they may be freed as soon as this returns. It
154 /// cannot fail and returns nothing. The setting survives [`needle_reset`] and
155 /// [`needle_init`], and [`needle_transcribe`] ignores it: that function takes its own.
156 ///
157 /// # Safety
158 ///
159 /// - `language` and `keywords` must each be either null or a pointer to a NUL-terminated C
160 /// string valid for the duration of the call.
161 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model; the
162 /// caller must serialise every call in this module.
163 pub fn needle_set_audio(
164 language: *const c_char,
165 keywords: *const c_char,
166 word_timestamps: c_int,
167 );
168
169 /// Transcribes one speech clip and writes a JSON envelope into `out`.
170 ///
171 /// Returns the number of tokens generated on success and a negative value on failure, with
172 /// the reason in [`needle_last_error`]. The envelope has the shape
173 /// `{"text":"...","language":"en","ttft_ms":0.0,"decode_tps":0.0}`; a non-zero
174 /// `word_timestamps` adds `"words":[{"word":"...","start":0.00,"end":0.00,"probability":0.000}]`
175 /// with times in seconds. Silence and steady noise give an empty `text` and `language`.
176 ///
177 /// `language` is one of `"en"`, `"de"`, `"fr"`, `"es"`, `"it"`, `"nl"` or `"pl"`, or null to
178 /// detect it. Codes are case-sensitive, the empty string also means detect, and any other
179 /// code fails with `unknown language <code>`. Forcing a language steers the decoder and
180 /// labels the result; it does not translate. `keywords` is null or newline-separated words
181 /// and phrases to bias the decoder towards. An empty clip (`samples` 0, `pcm` non-null) is
182 /// not an error: it gives the silence envelope.
183 ///
184 /// `out` follows the rules of [`needle_complete`]: always NUL-terminated and **silently
185 /// truncated** to `out_capacity - 1` bytes when it does not fit, with the token count still
186 /// returned. Unlike [`needle_complete`], an `out_capacity` of `0` or a null `out` fails with
187 /// "no output buffer". A failed call writes no envelope; the reason is only in
188 /// [`needle_last_error`].
189 ///
190 /// # Safety
191 ///
192 /// - `pcm` must be valid for reads of `samples` `f32` values for the duration of the call,
193 /// holding 16 kHz mono audio in `[-1, 1]`. `samples` must not be negative and must not
194 /// exceed 480 000 (30 seconds).
195 /// - `language` and `keywords` must each be either null or a pointer to a NUL-terminated C
196 /// string valid for the duration of the call.
197 /// - `out` must be null or valid for writes of `out_capacity` bytes, and `out_capacity`
198 /// must not be negative or larger than the allocation behind `out`.
199 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model; the
200 /// caller must serialise every call in this module.
201 /// - A speech model must have been loaded with [`needle_load`] first, otherwise this call
202 /// fails.
203 pub fn needle_transcribe(
204 pcm: *const c_float,
205 samples: c_int,
206 language: *const c_char,
207 keywords: *const c_char,
208 word_timestamps: c_int,
209 out: *mut c_char,
210 out_capacity: c_int,
211 ) -> c_int;
212
213 /// Embeds text or a speech clip, or reports how many floats that takes.
214 ///
215 /// Exactly one of `input` and `pcm` is non-null. Text yields one vector of the model's
216 /// embedding dimension (3072 for Needle 3, and asking with an empty string returns it
217 /// without computing). Speech yields one row of the speech model's width (512 for the
218 /// pinned Whistle, not the text dimension) per 80 ms frame, flattened, with
219 /// `frames = max(1, (samples + 1120) / 1280)`: an empty clip still gives one row. With
220 /// `out` null, returns that float count without computing anything. With a buffer, returns
221 /// the same count after filling it, or a negative value when `out_capacity` is too small or
222 /// the call fails, with the reason in
223 /// [`needle_last_error`]. Query the count first and size the buffer from the answer.
224 ///
225 /// # Safety
226 ///
227 /// - Exactly one of `input` and `pcm` must be non-null.
228 /// - `input` must be null or a pointer to a NUL-terminated C string valid for the call.
229 /// - `pcm` must be null, or valid for reads of `samples` `f32` values for the duration of
230 /// the call, holding 16 kHz mono audio in `[-1, 1]`. `samples` must not be negative and
231 /// must not exceed 480 000 (30 seconds); with `pcm` null it is ignored and `0` is
232 /// conventional.
233 /// - `out` must be null, or valid for writes of `out_capacity` `f32` values; `out_capacity`
234 /// must not be negative or exceed the allocation behind `out`.
235 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model; the
236 /// caller must serialise every call in this module.
237 /// - A model of the input's kind must have been loaded with [`needle_load`] first.
238 pub fn needle_embed(
239 input: *const c_char,
240 pcm: *const c_float,
241 samples: c_int,
242 out: *mut c_float,
243 out_capacity: c_int,
244 ) -> c_int;
245
246 /// Rewinds the conversation to the prefix that [`needle_init`] built.
247 ///
248 /// Tool declarations and the system prompt survive; only the turns taken since are dropped.
249 /// It cannot fail and it does not unload weights. Nothing in this API does.
250 ///
251 /// # Safety
252 ///
253 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model; the
254 /// caller must serialise every call in this module. In particular this must not run while
255 /// any other call, of either kind, is in flight.
256 pub fn needle_reset();
257
258 /// Loads a `.cact` weight archive from memory.
259 ///
260 /// The engine reads whichever kind of model the archive holds, text or speech, and keeps a
261 /// model of the other kind already loaded, so a process with both takes one call per
262 /// archive. Both kinds carry the same magic tag: check [`needle_models`] afterwards to learn
263 /// which one was loaded.
264 ///
265 /// Returns `0` on success and a negative value on failure, including for a null pointer or
266 /// bytes that are not a valid archive, with the reason in [`needle_last_error`]. The engine
267 /// **copies** what it needs during the call, so the buffer may be freed as soon as it
268 /// returns.
269 ///
270 /// Weights cannot be unloaded. Loading a text archive again, even the one already loaded,
271 /// discards the [`needle_init`] configuration; the speech model has none to lose.
272 ///
273 /// # Safety
274 ///
275 /// - `cact` must be null, or valid for reads of `n` bytes for the duration of the call.
276 /// - `n` must be the length of that buffer in bytes.
277 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model; the
278 /// caller must serialise every call in this module.
279 pub fn needle_load(cact: *const c_uchar, n: c_ulonglong) -> c_int;
280
281 /// Reports which kinds of model this process has loaded.
282 ///
283 /// Returns a bitmask of [`NEEDLE_TEXT`] and [`NEEDLE_SPEECH`]: `0` before any successful
284 /// [`needle_load`], and never fewer bits afterwards, since nothing unloads weights. It cannot
285 /// fail.
286 ///
287 /// # Safety
288 ///
289 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model; the
290 /// caller must serialise every call in this module.
291 pub fn needle_models() -> c_int;
292
293 /// Returns the reason the last failing call failed.
294 ///
295 /// The result is a NUL-terminated C string **owned by the runtime**. It is one
296 /// process-global slot shared by both kinds of model, and it is valid only until the next
297 /// call into this module, which may overwrite or free it: copy it out before calling
298 /// anything else, `needle_last_error` included.
299 ///
300 /// # Safety
301 ///
302 /// - The returned pointer may be null; check before dereferencing it.
303 /// - It must not be written through, freed, or read after the next call into this module.
304 /// - The engine is one process-global, non-thread-safe runtime for both kinds of model; the
305 /// caller must serialise every call in this module, and must copy the string before
306 /// releasing whatever serialises them.
307 pub fn needle_last_error() -> *const c_char;
308}