Files
anyllm-proxy/docs/library-integration.md
T
whit3rabbitandClaude Sonnet 4.6 f1df50ff37 refactor: extract shared client crate and rename to anyllm_*
Introduce `anyllm_client` crate containing HTTP client construction,
SSRF-safe DNS resolver, retry/backoff logic, rate limit header parsing,
and SSE frame parsing. These were previously inlined in the proxy crate.

Rename crates from `anthropic_openai_proxy`/`anthropic_openai_translate`
to `anyllm_proxy`/`anyllm_translate` throughout.

proxy/backend: now re-exports retry, rate limit, and SSE symbols from
the client crate; `send_with_retry` and `build_http_client` are thin
adapters bridging BackendAuth/TlsConfig to the client crate's types.

streaming: remove duplicate `find_double_newline` and
`MAX_SSE_BUFFER_SIZE` definitions; import from `crate::backend` instead.

Fix missing `pub mod` declarations in translator and proxy that were
accidentally replaced by doc comments (streaming, usage_map, server,
redact).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 06:11:01 -05:00

626 lines
23 KiB
Markdown

# Library Integration Guide
Research deliverable for Phase 21b. Evaluates how non-Rust consumers can use the `anyllm_translate` crate's translation logic without running the proxy as a separate process.
## Overview
The translator crate is pure Rust: no IO, no async, no network. All mapping is stateless `fn(A) -> B` (except streaming, which holds a small state machine). This makes it an ideal candidate for cross-language bindings.
**The core constraint:** public types use `serde_json::Value`, `#[serde(untagged)]` enums, `#[serde(flatten)]` with `serde_json::Map`, and deeply nested `Vec<ContentBlock>`. These cannot be represented as C structs. All FFI approaches must use **JSON strings as the boundary**: callers pass JSON in, get JSON back. This is consistent with how the proxy already works (HTTP JSON in/out) and avoids exposing Rust-specific type complexity.
| Approach | Target Languages | Build Tool | Package Format |
|---|---|---|---|
| C FFI (cbindgen) | Any with C FFI (Python, Node, Go, Ruby, Java) | cargo + cbindgen | `.so` / `.dylib` / `.dll` + `.h` |
| WASM (wasm-bindgen) | JS/TS, any WASM host | wasm-pack | `.wasm` + JS glue / npm package |
| PyO3 (maturin) | Python | maturin | wheel / PyPI package |
Dependencies that matter for cross-compilation:
- `serde`, `serde_json`, `thiserror`: all targets, no issues.
- `tracing`: all targets, no issues (events are no-ops without a subscriber).
- `uuid` v4: calls `getrandom` internally. Blocks `wasm32-unknown-unknown` without the `js` feature on `getrandom`.
## C FFI via cbindgen
### Concept
A thin `extern "C"` wrapper module that accepts and returns `*const c_char` (null-terminated JSON strings). The caller is responsible for freeing returned strings via a dedicated free function. Errors are stored in a thread-local and retrieved separately.
### Cargo.toml Changes
```toml
[lib]
crate-type = ["rlib", "cdylib"] # rlib for Rust consumers, cdylib for shared library
[features]
ffi = [] # gate FFI wrapper code behind a feature
```
### Wrapper API (8 functions)
```rust
// crates/translator/src/ffi.rs (gated behind #[cfg(feature = "ffi")])
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::cell::RefCell;
thread_local! {
static LAST_ERROR: RefCell<Option<String>> = RefCell::new(None);
}
fn set_error(msg: String) {
LAST_ERROR.with(|e| *e.borrow_mut() = Some(msg));
}
/// Returns the last error message, or null if no error.
/// Caller must free the returned string with `translate_free_string`.
#[no_mangle]
pub extern "C" fn translate_last_error() -> *const c_char {
LAST_ERROR.with(|e| {
match e.borrow().as_ref() {
Some(msg) => CString::new(msg.as_str()).unwrap().into_raw(),
None => std::ptr::null(),
}
})
}
/// Translate an Anthropic request to OpenAI format.
/// config_json: TranslationConfig as JSON string.
/// request_json: Anthropic MessageCreateRequest as JSON string.
/// Returns OpenAI ChatCompletionRequest as JSON string, or null on error.
#[no_mangle]
pub extern "C" fn translate_request_ffi(
config_json: *const c_char,
request_json: *const c_char,
) -> *const c_char {
let config_str = unsafe { CStr::from_ptr(config_json) }.to_str().unwrap();
let request_str = unsafe { CStr::from_ptr(request_json) }.to_str().unwrap();
let config: TranslationConfig = match serde_json::from_str(config_str) {
Ok(c) => c,
Err(e) => { set_error(e.to_string()); return std::ptr::null(); }
};
let request: MessageCreateRequest = match serde_json::from_str(request_str) {
Ok(r) => r,
Err(e) => { set_error(e.to_string()); return std::ptr::null(); }
};
match crate::translate_request(&request, &config) {
Ok(openai_req) => {
let json = serde_json::to_string(&openai_req).unwrap();
CString::new(json).unwrap().into_raw()
}
Err(e) => { set_error(e.to_string()); std::ptr::null() }
}
}
/// Translate an OpenAI response back to Anthropic format.
#[no_mangle]
pub extern "C" fn translate_response_ffi(
response_json: *const c_char,
original_model: *const c_char,
) -> *const c_char { /* similar pattern */ }
/// Create a new streaming translator. Returns an opaque handle.
#[no_mangle]
pub extern "C" fn stream_translator_new(
model: *const c_char,
) -> *mut StreamingTranslator { /* Box::into_raw(Box::new(...)) */ }
/// Feed a chunk to the streaming translator.
/// Returns a JSON array of Anthropic SSE events, or null on error.
#[no_mangle]
pub extern "C" fn stream_translator_process_chunk(
handle: *mut StreamingTranslator,
chunk_json: *const c_char,
) -> *const c_char { /* deserialize chunk, call process_chunk, serialize events */ }
/// Finalize the streaming translator. Returns remaining events as JSON array.
#[no_mangle]
pub extern "C" fn stream_translator_finish(
handle: *mut StreamingTranslator,
) -> *const c_char { /* call finish(), serialize events */ }
/// Free a streaming translator handle.
#[no_mangle]
pub extern "C" fn stream_translator_free(handle: *mut StreamingTranslator) {
if !handle.is_null() { unsafe { drop(Box::from_raw(handle)); } }
}
/// Free a string returned by any translate function.
#[no_mangle]
pub extern "C" fn translate_free_string(ptr: *mut c_char) {
if !ptr.is_null() { unsafe { drop(CString::from_raw(ptr)); } }
}
```
### cbindgen Configuration
```toml
# cbindgen.toml
language = "C"
header = "/* Auto-generated by cbindgen. Do not edit. */"
include_guard = "ANTHROPIC_OPENAI_TRANSLATE_H"
autogen_warning = "/* Warning: this file is auto-generated by cbindgen. */"
[export]
exclude = [] # only extern "C" functions are exported
```
Build: `cbindgen --config cbindgen.toml --crate anyllm_translate --output anyllm_translate.h`
### Language Binding Examples
**Python (ctypes):**
```python
import ctypes
import json
lib = ctypes.CDLL("./target/release/libanyllm_translate.so")
lib.translate_request_ffi.restype = ctypes.c_char_p
lib.translate_request_ffi.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
lib.translate_free_string.argtypes = [ctypes.c_char_p]
config = json.dumps({"model_map": [["haiku", "gpt-4o-mini"]], "lossy_behavior": "Warn"})
request = json.dumps({"model": "claude-haiku-3", "max_tokens": 1024, "messages": [...]})
result_ptr = lib.translate_request_ffi(config.encode(), request.encode())
if result_ptr:
result = json.loads(result_ptr.decode())
lib.translate_free_string(result_ptr)
```
**Node.js (koffi):**
```javascript
const koffi = require('koffi');
const lib = koffi.load('./target/release/libanyllm_translate.so');
const translate_request = lib.func('const char* translate_request_ffi(const char*, const char*)');
const free_string = lib.func('void translate_free_string(char*)');
const config = JSON.stringify({ model_map: [["haiku", "gpt-4o-mini"]] });
const request = JSON.stringify({ model: "claude-haiku-3", max_tokens: 1024, messages: [...] });
const result = translate_request(config, request); // koffi handles string conversion
const parsed = JSON.parse(result);
```
**Go (cgo):**
```go
// #cgo LDFLAGS: -L./target/release -lanyllm_translate
// #include "anyllm_translate.h"
import "C"
import "unsafe"
func TranslateRequest(config, request string) (string, error) {
cConfig := C.CString(config)
defer C.free(unsafe.Pointer(cConfig))
cRequest := C.CString(request)
defer C.free(unsafe.Pointer(cRequest))
result := C.translate_request_ffi(cConfig, cRequest)
if result == nil {
errMsg := C.translate_last_error()
defer C.translate_free_string((*C.char)(unsafe.Pointer(errMsg)))
return "", fmt.Errorf("%s", C.GoString(errMsg))
}
defer C.translate_free_string((*C.char)(unsafe.Pointer(result)))
return C.GoString(result), nil
}
```
### Verdict
**Feasible.** The JSON boundary makes it straightforward. The main cost is per-language integration effort: each consumer needs its own FFI loading code, string marshaling, and memory management discipline. Platform-specific builds (linux/macos/windows) add CI complexity.
Best for: languages without WASM support, performance-critical native services, environments where a shared library is easier to deploy than a sidecar process.
## WASM via wasm-bindgen
### Concept
Compile to `wasm32-unknown-unknown` and expose functions via `wasm-bindgen`. Callers interact through JS-native strings and classes. A single `.wasm` artifact works in browsers, Node.js, Deno, and edge runtimes (Cloudflare Workers, Vercel Edge).
### Blocker: `uuid` v4 and `getrandom`
The `uuid` crate's `v4` feature calls `getrandom` for OS entropy. On `wasm32-unknown-unknown`, `getrandom` has no default entropy source. The fix:
```toml
# Cargo.toml (translator crate)
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
```
This wires `crypto.getRandomValues()` as the entropy source. Requires a JS environment (browser or Node.js), which is always the case for `wasm-bindgen` targets.
Alternative (more complex): feature-gate `uuid` behind `cfg(not(target_arch = "wasm32"))` and accept IDs as input parameters in WASM mode. Not recommended; the `getrandom` js feature is simpler.
### Cargo.toml Changes
```toml
[lib]
crate-type = ["rlib", "cdylib"] # cdylib needed for wasm-pack
[features]
wasm = ["wasm-bindgen"]
[dependencies]
wasm-bindgen = { version = "0.2", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
```
### Wrapper API
```rust
// crates/translator/src/wasm.rs (gated behind #[cfg(feature = "wasm")])
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn translate_request(config_json: &str, request_json: &str) -> Result<String, JsValue> {
let config: TranslationConfig = serde_json::from_str(config_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let request: MessageCreateRequest = serde_json::from_str(request_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let openai_req = crate::translate_request(&request, &config)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&openai_req)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen]
pub fn translate_response(response_json: &str, original_model: &str) -> Result<String, JsValue> {
let response: ChatCompletionResponse = serde_json::from_str(response_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let anthropic_resp = crate::translate_response(&response, original_model);
serde_json::to_string(&anthropic_resp)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen]
pub struct WasmStreamingTranslator {
inner: StreamingTranslator,
}
#[wasm_bindgen]
impl WasmStreamingTranslator {
#[wasm_bindgen(constructor)]
pub fn new(model: &str) -> Self {
Self { inner: crate::new_stream_translator(model.to_string()) }
}
/// Feed an OpenAI chunk (JSON string), returns Anthropic events (JSON array string).
pub fn process_chunk(&mut self, chunk_json: &str) -> Result<String, JsValue> {
let chunk: ChatCompletionChunk = serde_json::from_str(chunk_json)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let events = self.inner.process_chunk(&chunk);
serde_json::to_string(&events)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
pub fn finish(&mut self) -> Result<String, JsValue> {
let events = self.inner.finish();
serde_json::to_string(&events)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}
```
### Build and Packaging
```bash
# Install wasm-pack
cargo install wasm-pack
# Build for bundlers (webpack, vite)
wasm-pack build crates/translator --features wasm --target bundler
# Build for vanilla JS (no bundler)
wasm-pack build crates/translator --features wasm --target web
# Build for Node.js
wasm-pack build crates/translator --features wasm --target nodejs
```
Output: `crates/translator/pkg/` with `.wasm`, `.js` glue, `package.json`, TypeScript definitions.
### Usage Examples
**Browser (ES module):**
```javascript
import init, { translate_request, WasmStreamingTranslator } from '@anthropic-openai-translate/wasm';
await init(); // load WASM binary
const config = JSON.stringify({ model_map: [["haiku", "gpt-4o-mini"]] });
const request = JSON.stringify({ model: "claude-haiku-3", max_tokens: 1024, messages: [...] });
const openaiJson = translate_request(config, request);
const openaiReq = JSON.parse(openaiJson);
// Streaming
const translator = new WasmStreamingTranslator("gpt-4o");
for await (const chunk of openaiStream) {
const eventsJson = translator.process_chunk(JSON.stringify(chunk));
const events = JSON.parse(eventsJson);
// emit events to client
}
const finalEvents = JSON.parse(translator.finish());
```
**Node.js:**
```javascript
const { translate_request } = require('@anthropic-openai-translate/wasm');
// No init() needed for Node.js target
const result = translate_request(configJson, requestJson);
```
**Cloudflare Worker:**
```javascript
import { translate_request } from '@anthropic-openai-translate/wasm';
export default {
async fetch(request) {
const body = await request.json();
const openaiJson = translate_request(configJson, JSON.stringify(body));
return fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Authorization": `Bearer ${env.OPENAI_API_KEY}` },
body: openaiJson,
});
}
};
```
### Size Estimate
Similar pure-serde WASM crates compile to 500KB-2MB after `wasm-opt -Oz`. The `serde_json` dependency is the largest contributor. No way to avoid it since JSON parsing is core functionality.
### Verdict
**Feasible with one fix** (add `getrandom` js feature for WASM targets). Single artifact works everywhere JS runs. The main tradeoff is performance: serde in WASM is roughly 2-5x slower than native for JSON parsing. For a translation layer (not a hot loop), this is acceptable.
Best for: browser-based tools, edge runtimes (Cloudflare Workers, Deno Deploy), sandboxed environments, anywhere a native binary cannot be deployed.
## PyO3 Native Python Module
### Concept
Use `pyo3` and `maturin` to build a native Python extension module (`.so` on Linux/macOS, `.pyd` on Windows). Python sees it as a regular importable module. Published to PyPI as `pip install anthropic-openai-translate`.
### Cargo.toml Changes
```toml
[lib]
crate-type = ["rlib", "cdylib"]
[features]
python = ["pyo3"]
[dependencies]
pyo3 = { version = "0.22", features = ["extension-module"], optional = true }
```
### Wrapper API
```rust
// crates/translator/src/python.rs (gated behind #[cfg(feature = "python")])
use pyo3::prelude::*;
use pyo3::exceptions::PyValueError;
pyo3::create_exception!(anyllm_translate, TranslateError, pyo3::exceptions::PyException);
#[pyfunction]
fn translate_request(config_json: &str, request_json: &str) -> PyResult<String> {
let config: TranslationConfig = serde_json::from_str(config_json)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let request: MessageCreateRequest = serde_json::from_str(request_json)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let openai_req = crate::translate_request(&request, &config)
.map_err(|e| TranslateError::new_err(e.to_string()))?;
serde_json::to_string(&openai_req)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
#[pyfunction]
fn translate_response(response_json: &str, original_model: &str) -> PyResult<String> {
let response: ChatCompletionResponse = serde_json::from_str(response_json)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let anthropic_resp = crate::translate_response(&response, original_model);
serde_json::to_string(&anthropic_resp)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
#[pyclass]
struct StreamingTranslator {
inner: crate::mapping::streaming_map::StreamingTranslator,
}
#[pymethods]
impl StreamingTranslator {
#[new]
fn new(model: &str) -> Self {
Self { inner: crate::new_stream_translator(model.to_string()) }
}
fn process_chunk(&mut self, chunk_json: &str) -> PyResult<String> {
let chunk: ChatCompletionChunk = serde_json::from_str(chunk_json)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
let events = self.inner.process_chunk(&chunk);
serde_json::to_string(&events)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
fn finish(&mut self) -> PyResult<String> {
let events = self.inner.finish();
serde_json::to_string(&events)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
}
#[pymodule]
fn anyllm_translate(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(translate_request, m)?)?;
m.add_function(wrap_pyfunction!(translate_response, m)?)?;
m.add_class::<StreamingTranslator>()?;
m.add("TranslateError", m.py().get_type::<TranslateError>())?;
Ok(())
}
```
### Type Stubs
```python
# anyllm_translate.pyi
def translate_request(config_json: str, request_json: str) -> str: ...
def translate_response(response_json: str, original_model: str) -> str: ...
class StreamingTranslator:
def __init__(self, model: str) -> None: ...
def process_chunk(self, chunk_json: str) -> str: ...
def finish(self) -> str: ...
class TranslateError(Exception): ...
```
### Build and Publishing
```bash
# Install maturin
pip install maturin
# Development build (installs into current venv)
maturin develop --features python
# Build wheel
maturin build --release --features python
# Publish to PyPI
maturin publish --features python
```
### Usage Example
```python
import json
import anyllm_translate as translator
config = json.dumps({
"model_map": [["haiku", "gpt-4o-mini"], ["sonnet", "gpt-4o"]],
"lossy_behavior": "Warn",
"passthrough_unknown_models": True,
})
# Non-streaming
request = json.dumps({
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
})
openai_json = translator.translate_request(config, request)
openai_req = json.loads(openai_json)
# Send to OpenAI, get response...
anthropic_json = translator.translate_response(openai_response_json, "claude-sonnet-4-20250514")
# Streaming
stream = translator.StreamingTranslator("gpt-4o")
for chunk in openai_stream:
events_json = stream.process_chunk(json.dumps(chunk))
events = json.loads(events_json)
for event in events:
yield event
final_events = json.loads(stream.finish())
```
### Optional: Pure-Python Convenience Wrapper
A thin Python wrapper can accept/return dicts instead of JSON strings, hiding the serialization:
```python
# anyllm_translate/convenience.py (pure Python, wraps native module)
import json
import anyllm_translate._native as _native
def translate_request(config: dict, request: dict) -> dict:
return json.loads(_native.translate_request(json.dumps(config), json.dumps(request)))
```
This adds one extra serialize/deserialize cycle but gives users a more Pythonic API. The performance cost is negligible compared to network latency.
### CI: Cross-Platform Wheels
`maturin` provides GitHub Actions templates for building wheels across platforms:
```yaml
# .github/workflows/python.yml (sketch)
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: PyO3/maturin-action@v1
with:
command: build
args: --release --features python
```
### Verdict
**Feasible and recommended as the first FFI target.** Python is the dominant LLM ecosystem language. PyO3 + maturin is mature tooling with excellent cross-platform support. The JSON boundary keeps the wrapper thin (~50 lines of Rust). Publishing to PyPI gives broad reach.
Best for: Python codebases, data pipelines, LLM orchestration frameworks (LangChain, LlamaIndex), Jupyter notebooks.
## Integration Pattern Comparison
| | Rust Crate | C FFI | WASM | PyO3 | Proxy Sidecar |
|---|---|---|---|---|---|
| **When to use** | Rust codebases | Native polyglot services | Browser, edge, Node.js | Python codebases | Any language, quick start |
| **Latency** | Lowest (native call) | Low (call + JSON ser/de) | Medium (WASM + JSON) | Low (call + JSON ser/de) | High (HTTP round-trip) |
| **Integration complexity** | Lowest (cargo dep) | High (per-language FFI) | Medium (wasm-pack) | Low (pip install) | Lowest for consumer |
| **Deployment** | Compile-time | Ship .so/.dylib + .h | Ship .wasm + JS | pip wheel | Docker / binary |
| **Language support** | Rust only | Any with C FFI | JS/TS, WASM hosts | Python only | Any (HTTP) |
| **Streaming** | Native iterators | Opaque handle | JS class | Python class | SSE passthrough |
| **Ops overhead** | None | Build matrix | Single artifact | maturin CI matrix | Running process |
| **Binary size** | N/A (linked in) | ~2-5MB shared lib | 500KB-2MB .wasm | ~2-5MB wheel | ~10MB binary |
### Decision Tree
1. **Already using Rust?** Use the crate as a cargo dependency. Zero overhead, full type safety.
2. **Python project?** Use the PyO3 module. `pip install`, native speed, Pythonic exceptions.
3. **Browser or edge runtime?** Use WASM. Only option for client-side, single artifact.
4. **Other language (Go, Java, Ruby)?** Use C FFI if performance matters, proxy sidecar if simplicity matters.
5. **Don't want to write code?** Run the proxy as a sidecar (Docker or binary). Any language with an HTTP client works.
## Recommendations
**Implementation priority:** PyO3 > WASM > C FFI.
Rationale:
1. **PyO3 first.** Python dominates the LLM ecosystem. The tooling (maturin) is mature. Highest impact per effort.
2. **WASM second.** Browser and edge are growing deployment targets for LLM tools. Single artifact simplifies distribution.
3. **C FFI third (or skip).** High integration effort per language. Most non-Rust/non-Python/non-JS users are better served by the proxy sidecar. Implement only if specific demand exists.
For most users, the **proxy sidecar** (existing binary/Docker image) remains the simplest integration path. The FFI options serve users who cannot or prefer not to run a separate process.
## Known Blockers and Mitigations
| Blocker | Affects | Severity | Mitigation |
|---|---|---|---|
| `uuid` v4 needs `getrandom` with `js` feature | WASM | Build failure | Add `getrandom = { version = "0.2", features = ["js"] }` as a target-specific dependency for `wasm32` |
| `tracing` in non-Rust environments | All FFI | Non-issue | Events are no-ops without a subscriber. Consumers can optionally install `tracing-wasm` (WASM) or ignore entirely |
| Platform-specific builds | C FFI, PyO3 | CI complexity | Use `cross` (C FFI) or `maturin-action` (PyO3) in GitHub Actions for build matrix |
| `crate-type = ["cdylib"]` required | C FFI, WASM, PyO3 | Build config | Add `cdylib` alongside `rlib`. Feature-gate wrapper modules: `#[cfg(feature = "ffi")]`, `#[cfg(feature = "wasm")]`, `#[cfg(feature = "python")]` |
| Multiple cdylib targets conflict | All FFI | Build config | Only one FFI feature active per build. Separate CI jobs for each target |
| `serde_json::Value` in public types | All FFI | Design | Already solved by JSON string boundary. Types never cross FFI; only serialized JSON does |