bankml / capi /src /lib.rs
Gregory-L's picture
bankML: the whole source (github.com/cryptoAGI/bankml @ 12ae409) and its page, with the bankML persona; the live engine (Dockerfile, hf/start.sh) ready for Docker hardware
28c70af verified
Raw History Blame Contribute Delete
18.6 kB
// SPDX-License-Identifier: MIT OR Apache-2.0
//! The C ABI of `libbankml.so` / `libbankml.a`, declared in `capi/include/bankml.h`.
//!
//! `bankml_open` verifies a model as `bankml serve` does and opens it in bankML's forward pass; `bankml_chat` runs
//! one `serve --native` chat request through serve's own code path; `bankml_log` is a C-variadic printf defined in
//! Rust (`printf.rs`). No panic unwinds into C: every entry point runs under `catch_unwind` and reports an error code
//! or NULL with a message. Calls on one handle serialize.
//! Details: docs/modules/capi.md.
use engine::native::{engine_name, header_info, Native};
use engine::serve::{completion_json, ident, FileIdent, Json, NativeChat, Tally};
use engine::Verified;
use std::ffi::{c_char, c_int, c_void, CStr, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod printf;
/// Success. `bankml_chat` returns this or one of the `BANKML_E_*` codes (as in bankml.h).
pub const BANKML_OK: c_int = 0;
/// A NULL handle or request, or a request that is not UTF-8.
pub const BANKML_E_ARG: c_int = -1;
/// The request is refused: not JSON, no messages, a sampler bankML does not reproduce, or a malformed `stop`.
pub const BANKML_E_REQUEST: c_int = -2;
/// The model file changed since `bankml_open` verified it; no answer is given. Reopen to verify it again.
pub const BANKML_E_CHANGED: c_int = -3;
/// The engine failed (for example, the prompt does not fit the context).
pub const BANKML_E_ENGINE: c_int = -4;
/// A panic was caught (unwinding builds only).
pub const BANKML_E_PANIC: c_int = -5;
/// A verified model open in bankML's forward pass, with one slot. Opaque to C (`bankml_t`).
pub struct Bankml {
native: Native,
verified: Verified,
engine: String,
/// The canonical path that was hashed, and its identity at that time.
model: PathBuf,
ident: FileIdent,
model_id: String,
run: Mutex<()>,
}
/// Streaming callback `int (*)(const char* piece, size_t len, void* user)`; returns 0 to stop.
pub type PieceCb = unsafe extern "C" fn(piece: *const c_char, len: usize, user: *mut c_void) -> c_int;
/// Log sink: level, message (NUL-terminated, `len` bytes before the NUL), user pointer.
pub type LogCb = unsafe extern "C" fn(level: c_int, msg: *const c_char, len: usize, user: *mut c_void);
fn to_c(s: &str) -> *mut c_char {
// An interior NUL would truncate the message in C; it is replaced by U+2400.
CString::new(s.replace('\0', "\u{2400}")).map(CString::into_raw).unwrap_or(std::ptr::null_mut())
}
unsafe fn path_arg(p: *const c_char, what: &str) -> Result<PathBuf, String> {
use std::os::unix::ffi::OsStrExt;
if p.is_null() {
return Err(format!("{what} is NULL"));
}
let b = unsafe { CStr::from_ptr(p) }.to_bytes();
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(b)))
}
/// The library version, e.g. `"0.3.2"`. Static storage; do not free.
#[no_mangle]
pub extern "C" fn bankml_version() -> *const c_char {
static V: OnceLock<CString> = OnceLock::new();
V.get_or_init(|| CString::new(engine::VERSION).unwrap_or_default()).as_ptr()
}
fn open(model: &Path, fork_json: &Path, n_ctx: u32) -> Result<Bankml, String> {
let fork = std::fs::read_to_string(fork_json).map_err(|e| format!("cannot read {}: {e}", fork_json.display()))?;
// As `bankml serve`: the file's identity must be the same before and after the hash.
let before = ident(model).map_err(|e| format!("{}: {e}", model.display()))?;
let verified = engine::verify(model, &fork, engine::gguf::Engine::Mainline).map_err(|e| format!("refuse: {e}"))?;
let canon = model.canonicalize().map_err(|e| format!("{}: {e}", model.display()))?;
let id = ident(&canon).map_err(|e| format!("{}: {e}", canon.display()))?;
if id != before {
return Err(format!("{} changed while it was being hashed: refused", canon.display()));
}
// The name as given (for messages, as serve uses it) and the canonical file name (serve's `model` field).
let given = model.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let model_id = canon.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if let Err(why) = header_info(&canon).native {
return Err(format!("refuse: {given}: bankML's native forward pass does not play it: {why}"));
}
let n_ctx = if n_ctx == 0 { 4096 } else { n_ctx as usize }; // serve's default
let native = Native::open(&canon, n_ctx)?;
engine::log(engine::LOG_INFO, &format!("bankml: {given} verified (sha256 {}); open in bankML's forward pass, context {n_ctx}", verified.model_sha256));
Ok(Bankml { engine: engine_name(&native), native, verified, model: canon, ident: id, model_id, run: Mutex::new(()) })
}
/// Verify `model_path` against the FORK.json at `fork_json_path` and open it in bankML's forward pass.
///
/// Verification is serve's: the guard, then the sha256 pin. The context is `n_ctx` tokens (0 means 4096). A model
/// the native forward pass does not play is refused with serve's reason. Returns a handle for `bankml_close`, or
/// NULL on refusal or error. `*err` (when `err` is not NULL) is set to NULL on success, or to the reason, which the
/// caller frees with `bankml_free`; the reason is also logged at error level.
///
/// # Safety
/// `model_path` and `fork_json_path` must be NULL or NUL-terminated strings; `err` must be NULL or point to a
/// writable `char*`.
#[no_mangle]
pub unsafe extern "C" fn bankml_open(model_path: *const c_char, fork_json_path: *const c_char, n_ctx: u32, err: *mut *mut c_char) -> *mut Bankml {
if !err.is_null() {
unsafe { *err = std::ptr::null_mut() };
}
let r = catch_unwind(AssertUnwindSafe(|| {
let model = unsafe { path_arg(model_path, "model_path") }?;
let fork = unsafe { path_arg(fork_json_path, "fork_json_path") }?;
open(&model, &fork, n_ctx)
}));
let why = match r {
Ok(Ok(h)) => return Box::into_raw(Box::new(h)),
Ok(Err(e)) => e,
Err(_) => "bankml_open: a panic was caught".to_string(),
};
engine::log(engine::LOG_ERROR, &format!("bankml_open: {why}"));
if !err.is_null() {
unsafe { *err = to_c(&why) };
}
std::ptr::null_mut()
}
fn chat(h: &Bankml, body: &[u8], cb: Option<PieceCb>, user: *mut c_void) -> Result<String, (c_int, String)> {
let _run = h.run.lock().unwrap_or_else(|e| e.into_inner());
// As `serve --native` before every answer: the file must still be the one that was verified.
if ident(&h.model).ok() != Some(h.ident) {
return Err((BANKML_E_CHANGED, format!("{} changed since bankml verified it: refused; bankml_open it again to verify it again", h.model_id)));
}
let text = std::str::from_utf8(body).map_err(|_| (BANKML_E_ARG, "request_json is not UTF-8".to_string()))?;
let req = Json::parse(text).ok_or((BANKML_E_REQUEST, "request_json is not JSON".to_string()))?;
let nc = NativeChat::parse(&h.native, &req, text).map_err(|e| (BANKML_E_REQUEST, e))?;
let mut t = Tally::new(body);
let created = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let mut buf = Vec::new();
let d = nc
.run(&h.native, &mut t, true, |piece| {
let Some(f) = cb else { return true };
if piece.is_empty() {
return true;
}
buf.clear();
buf.extend_from_slice(piece.as_bytes());
buf.push(0);
unsafe { f(buf.as_ptr().cast(), piece.len(), user) != 0 }
})
.map_err(|e| (BANKML_E_ENGINE, e))?;
Ok(completion_json(created, &h.model_id, &d, &t, &h.engine, &h.verified))
}
/// Run one chat completion on `h`.
///
/// `request_json` is an OpenAI / llama-server chat request, parsed by `serve --native`'s `NativeChat::parse`; what
/// bankML does not reproduce (mirostat, a custom sampler order, `top_k` outside 1–128) is refused. The answer
/// streams to `cb` (may be NULL) as whole, non-empty UTF-8 pieces, each NUL-terminated with `len` bytes before the
/// NUL; `cb` returns 0 to stop. `*result_json` (when not NULL) is set to the `/v1/chat/completions` object (`usage`,
/// `timings.cache_n`, `bankml_receipt`) or, on error, to `{"error": {"code": …, "message": …}}`; the caller frees
/// it with `bankml_free`. Returns `BANKML_OK` or a `BANKML_E_*` code.
///
/// # Safety
/// `h` must be NULL or a handle from `bankml_open` not yet closed; `request_json` NULL or a NUL-terminated string;
/// `result_json` NULL or a writable `char*`; `cb` (if any) must be callable with `user` and must not close `h` or
/// call `bankml_chat` on it (the handle's lock is held while `cb` runs).
#[no_mangle]
pub unsafe extern "C" fn bankml_chat(h: *mut Bankml, request_json: *const c_char, cb: Option<PieceCb>, user: *mut c_void, result_json: *mut *mut c_char) -> c_int {
if !result_json.is_null() {
unsafe { *result_json = std::ptr::null_mut() };
}
let r = catch_unwind(AssertUnwindSafe(|| {
if h.is_null() {
return Err((BANKML_E_ARG, "the handle is NULL".to_string()));
}
if request_json.is_null() {
return Err((BANKML_E_ARG, "request_json is NULL".to_string()));
}
let body = unsafe { CStr::from_ptr(request_json) }.to_bytes();
chat(unsafe { &*h }, body, cb, user)
}));
let (code, json) = match r {
Ok(Ok(j)) => (BANKML_OK, j),
Ok(Err((code, e))) => (code, format!("{{\"error\": {{\"code\": {code}, \"message\": {}}}}}", engine::gguf::jstr(&e))),
Err(_) => (BANKML_E_PANIC, format!("{{\"error\": {{\"code\": {BANKML_E_PANIC}, \"message\": \"a panic was caught\"}}}}")),
};
if !result_json.is_null() {
unsafe { *result_json = to_c(&json) };
}
code
}
/// Close a handle (NULL is a no-op): the model's mapping and its KV cache are released.
///
/// # Safety
/// `h` must be NULL or a handle from `bankml_open`, not used again, and not in use by another thread.
#[no_mangle]
pub unsafe extern "C" fn bankml_close(h: *mut Bankml) {
if !h.is_null() {
let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(h) })));
}
}
/// Free a string this library returned (`err`, `result_json`). NULL is a no-op.
///
/// # Safety
/// `s` must be NULL or a string from this library, freed once.
#[no_mangle]
pub unsafe extern "C" fn bankml_free(s: *mut c_char) {
if !s.is_null() {
drop(unsafe { CString::from_raw(s) });
}
}
// ---------------------------------------------------------------- the log ----------------------------------------
struct Sink {
cb: Option<LogCb>,
user: usize,
}
static SINK: Mutex<Sink> = Mutex::new(Sink { cb: None, user: 0 });
fn deliver(level: c_int, msg: &[u8]) {
let (cb, user) = {
let s = SINK.lock().unwrap_or_else(|e| e.into_inner());
(s.cb, s.user)
};
match cb {
Some(f) => {
let mut v = Vec::with_capacity(msg.len() + 1);
v.extend_from_slice(msg);
v.push(0);
unsafe { f(level, v.as_ptr().cast(), msg.len(), user as *mut c_void) }
}
None => {
use std::io::Write;
let mut e = std::io::stderr().lock();
let _ = e.write_all(msg);
let _ = e.write_all(b"\n");
}
}
}
fn from_engine(level: i32, msg: &str) {
deliver(level, msg.as_bytes());
}
/// Route every message, `bankml_log`'s and the library's own (verification, the GPU, …), to `cb` with `user`.
///
/// NULL restores standard error. The sink is process-wide.
///
/// # Safety
/// `cb` (if any) must stay callable with `user` until it is replaced; it may be called, possibly concurrently, from
/// any thread that calls into bankML, and must not call `bankml_set_log` itself.
#[no_mangle]
pub unsafe extern "C" fn bankml_set_log(cb: Option<LogCb>, user: *mut c_void) {
*SINK.lock().unwrap_or_else(|e| e.into_inner()) = Sink { cb, user: user as usize };
engine::set_log_sink(if cb.is_some() { Some(from_engine) } else { None });
}
/// Format a printf-style message and send it to the log sink at `level`.
///
/// `%d %i %u %x %X` (with `hh h l ll z`), `%f %F`, `%c %s %p %%`, flags `- + space # 0`, and width and precision
/// (numbers or `*`) are byte-identical to glibc's `snprintf`. Anything else is written as `%<unsupported:SPEC>`,
/// never guessed (see `printf.rs`). If formatting panics, nothing is logged.
///
/// # Safety
/// `fmt` must be NULL (nothing is logged) or a NUL-terminated string, and the arguments must match it as for
/// `printf`.
#[no_mangle]
pub unsafe extern "C" fn bankml_log(level: c_int, fmt: *const c_char, mut args: ...) {
if fmt.is_null() {
return;
}
let f = unsafe { CStr::from_ptr(fmt) }.to_bytes();
if let Ok(out) = catch_unwind(AssertUnwindSafe(|| unsafe { printf::format(f, &mut args) })) {
deliver(level, &out);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::{c_double, c_long, c_longlong, c_uint, c_ulong, c_ulonglong};
extern "C" {
fn snprintf(buf: *mut c_char, n: usize, fmt: *const c_char, ...) -> c_int;
}
static GOT: Mutex<Vec<u8>> = Mutex::new(Vec::new());
/// Serializes the tests that log: the sink is process-wide.
static SERIAL: Mutex<()> = Mutex::new(());
unsafe extern "C" fn capture(level: c_int, msg: *const c_char, len: usize, _user: *mut c_void) {
let b = unsafe { std::slice::from_raw_parts(msg.cast::<u8>(), len) };
assert_eq!(unsafe { *msg.add(len) }, 0, "NUL-terminated");
let mut g = GOT.lock().unwrap();
g.clear();
g.extend_from_slice(format!("{level}:").as_bytes());
g.extend_from_slice(b);
}
/// Formats the same arguments with `bankml_log` and libc's `snprintf` and asserts equal bytes.
macro_rules! same {
($fmt:expr $(, $a:expr)*) => {{
let fmt: &CStr = $fmt;
let mut buf = vec![0u8; 512];
let n = unsafe { snprintf(buf.as_mut_ptr().cast(), buf.len(), fmt.as_ptr() $(, $a)*) };
buf.truncate(n as usize);
unsafe { bankml_log(2, fmt.as_ptr() $(, $a)*) };
let got = GOT.lock().unwrap().clone();
assert_eq!(String::from_utf8_lossy(&got[2..]), String::from_utf8_lossy(&buf), "{:?}", fmt);
assert_eq!(&got[..2], b"2:");
}};
}
#[test]
fn bankml_log_equals_snprintf() {
let _turn = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
unsafe { bankml_set_log(Some(capture), std::ptr::null_mut()) };
let s = c"bankml";
same!(c"plain text, no conversions");
same!(c"%d %i %u %d %d", 0 as c_int, -1 as c_int, c_uint::MAX, c_int::MIN, c_int::MAX);
same!(c"[%5d|%-5d|%05d|%+d|% d|%.3d|%.0d|%8.3d]", 42 as c_int, 42 as c_int, -42 as c_int, 7 as c_int, 7 as c_int, 5 as c_int, 0 as c_int, -5 as c_int);
same!(c"%ld %lu %lld %llu %zu", c_long::MIN, c_ulong::MAX, c_longlong::MIN, c_ulonglong::MAX, usize::MAX);
same!(c"%x %X %#x %#X %08x %#010x %lx %llX", 255 as c_uint, 255 as c_uint, 255 as c_uint, 255 as c_uint, 0xbeef as c_uint, 0xbeef as c_uint, c_ulong::MAX, c_ulonglong::MAX);
same!(c"%f %.3f %.0f %.0f %.0f %#.0f %10.2f %-10.2f| %010.2f %+.1f %.20f", 3.14258 as c_double, -2.0005 as c_double, 0.5 as c_double,
1.5 as c_double, 2.5 as c_double, 3.0 as c_double, -1.005 as c_double, 1.0 as c_double, -3.25 as c_double, 0.0 as c_double, 0.1 as c_double);
same!(c"%f %F %f %5f %f %.2f %f", f64::INFINITY, f64::NEG_INFINITY, f64::NAN, f64::INFINITY, 1e300 as c_double, -0.0 as c_double, f64::MIN_POSITIVE);
same!(c"[%s|%8s|%-8s|%.4s|%.*s|%*s]", s.as_ptr(), s.as_ptr(), s.as_ptr(), s.as_ptr(), 2 as c_int, s.as_ptr(), -9 as c_int, s.as_ptr());
same!(c"%c%c|%3c|%-3c|", b'o' as c_int, b'k' as c_int, b'x' as c_int, b'y' as c_int);
same!(c"%p %p %20p %-20p|", 0x1234usize as *const c_void, std::ptr::null::<c_void>(), s.as_ptr(), s.as_ptr());
same!(c"100%% and %hhd %hd %hhu %hx", 300 as c_int, 70000 as c_int, 511 as c_uint, 0x12345 as c_uint);
// An unsupported conversion is marked, and nothing is read after it.
unsafe { bankml_log(1, c"%d %e %d".as_ptr(), 5 as c_int, 2.0 as c_double, 6 as c_int) };
assert_eq!(GOT.lock().unwrap().as_slice(), b"1:5 %<unsupported:e> %<skipped:d>");
// The library's own messages reach the same sink.
engine::log(engine::LOG_WARN, "from the engine");
assert_eq!(GOT.lock().unwrap().as_slice(), b"1:from the engine");
unsafe { bankml_set_log(None, std::ptr::null_mut()) };
}
#[test]
fn null_safe_and_refusing() {
let _turn = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(unsafe { CStr::from_ptr(bankml_version()) }.to_str().unwrap(), engine::VERSION);
let mut err = std::ptr::null_mut();
assert!(unsafe { bankml_open(std::ptr::null(), std::ptr::null(), 0, &mut err) }.is_null());
assert_eq!(unsafe { CStr::from_ptr(err) }.to_str().unwrap(), "model_path is NULL");
unsafe { bankml_free(err) };
// A file that is not a pinned GGUF is refused, with the reason.
let dir = std::env::temp_dir().join(format!("bankml-capi-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("m.gguf"), b"GGUX").unwrap();
std::fs::write(dir.join("FORK.json"), b"{\"files\": []}").unwrap();
let (m, f) = (CString::new(dir.join("m.gguf").to_str().unwrap()).unwrap(), CString::new(dir.join("FORK.json").to_str().unwrap()).unwrap());
assert!(unsafe { bankml_open(m.as_ptr(), f.as_ptr(), 0, &mut err) }.is_null());
assert!(unsafe { CStr::from_ptr(err) }.to_str().unwrap().starts_with("refuse: guard refused"));
unsafe { bankml_free(err) };
assert!(unsafe { bankml_open(m.as_ptr(), f.as_ptr(), 0, std::ptr::null_mut()) }.is_null()); // err may be NULL
std::fs::remove_dir_all(&dir).unwrap();
let mut res = std::ptr::null_mut();
assert_eq!(unsafe { bankml_chat(std::ptr::null_mut(), c"{}".as_ptr(), None, std::ptr::null_mut(), &mut res) }, BANKML_E_ARG);
assert!(unsafe { CStr::from_ptr(res) }.to_str().unwrap().contains("\"code\": -1"));
unsafe { bankml_free(res) };
unsafe { bankml_close(std::ptr::null_mut()) };
unsafe { bankml_free(std::ptr::null_mut()) };
unsafe { bankml_log(0, std::ptr::null()) };
}
}