use std::ffi::{c_char, c_int, c_void, CStr, CString};
use std::ptr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::InferenceError;
const AVAILABILITY_CACHE_TTL: Duration = Duration::from_secs(5);
#[cfg(car_fm_swift_built)]
extern "C" {
fn car_fm_is_available() -> c_int;
fn car_fm_free_string(ptr: *mut c_char);
fn car_fm_generate(
prompt: *const c_char,
instructions: *const c_char,
max_tokens: i32,
temperature: f64,
out_text: *mut *mut c_char,
out_err: *mut *mut c_char,
) -> c_int;
fn car_fm_generate_stream(
prompt: *const c_char,
instructions: *const c_char,
max_tokens: i32,
temperature: f64,
callback: extern "C" fn(token: *const c_char, state: *mut c_void) -> c_int,
state: *mut c_void,
out_err: *mut *mut c_char,
) -> c_int;
fn car_fm_generate_with_tools(
prompt: *const c_char,
instructions: *const c_char,
tools_json: *const c_char,
max_tokens: i32,
temperature: f64,
out_text: *mut *mut c_char,
out_tool_calls_json: *mut *mut c_char,
out_err: *mut *mut c_char,
) -> c_int;
fn car_fm_generate_structured(
prompt: *const c_char,
instructions: *const c_char,
schema_json: *const c_char,
max_tokens: i32,
temperature: f64,
out_json: *mut *mut c_char,
out_err: *mut *mut c_char,
) -> c_int;
}
#[cfg(not(car_fm_swift_built))]
mod swift_stubs {
use super::{c_char, c_int, c_void};
pub(super) unsafe fn car_fm_is_available() -> c_int {
0
}
pub(super) unsafe fn car_fm_free_string(_ptr: *mut c_char) {}
pub(super) unsafe fn car_fm_generate(
_prompt: *const c_char,
_instructions: *const c_char,
_max_tokens: i32,
_temperature: f64,
_out_text: *mut *mut c_char,
_out_err: *mut *mut c_char,
) -> c_int {
unreachable!("car_fm_generate called without the Swift bridge")
}
pub(super) unsafe fn car_fm_generate_stream(
_prompt: *const c_char,
_instructions: *const c_char,
_max_tokens: i32,
_temperature: f64,
_callback: extern "C" fn(token: *const c_char, state: *mut c_void) -> c_int,
_state: *mut c_void,
_out_err: *mut *mut c_char,
) -> c_int {
unreachable!("car_fm_generate_stream called without the Swift bridge")
}
pub(super) unsafe fn car_fm_generate_with_tools(
_prompt: *const c_char,
_instructions: *const c_char,
_tools_json: *const c_char,
_max_tokens: i32,
_temperature: f64,
_out_text: *mut *mut c_char,
_out_tool_calls_json: *mut *mut c_char,
_out_err: *mut *mut c_char,
) -> c_int {
unreachable!("car_fm_generate_with_tools called without the Swift bridge")
}
pub(super) unsafe fn car_fm_generate_structured(
_prompt: *const c_char,
_instructions: *const c_char,
_schema_json: *const c_char,
_max_tokens: i32,
_temperature: f64,
_out_json: *mut *mut c_char,
_out_err: *mut *mut c_char,
) -> c_int {
unreachable!("car_fm_generate_structured called without the Swift bridge")
}
}
#[cfg(not(car_fm_swift_built))]
use swift_stubs::{
car_fm_free_string, car_fm_generate, car_fm_generate_stream, car_fm_generate_structured,
car_fm_generate_with_tools, car_fm_is_available,
};
pub fn is_available() -> bool {
static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
let now = Instant::now();
let mut guard = match CACHE.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
if let Some((stamped, value)) = *guard {
if now.duration_since(stamped) < AVAILABILITY_CACHE_TTL {
return value;
}
}
let value = unsafe { car_fm_is_available() != 0 };
*guard = Some((now, value));
value
}
pub fn generate(
prompt: &str,
instructions: Option<&str>,
max_tokens: u32,
temperature: f32,
) -> Result<String, InferenceError> {
if !is_available() {
return Err(unavailable_error());
}
let prompt_c = CString::new(prompt)
.map_err(|e| InferenceError::InferenceFailed(format!("prompt has interior NUL: {e}")))?;
let instr_c = match instructions {
Some(s) if !s.is_empty() => Some(CString::new(s).map_err(|e| {
InferenceError::InferenceFailed(format!("instructions have interior NUL: {e}"))
})?),
_ => None,
};
let mut out_text: *mut c_char = ptr::null_mut();
let mut out_err: *mut c_char = ptr::null_mut();
let rc = unsafe {
car_fm_generate(
prompt_c.as_ptr(),
instr_c.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
max_tokens.min(i32::MAX as u32) as i32,
temperature as f64,
&mut out_text as *mut *mut c_char,
&mut out_err as *mut *mut c_char,
)
};
if rc != 0 {
return Err(InferenceError::InferenceFailed(consume_swift_string(
out_err,
)));
}
Ok(consume_swift_string(out_text))
}
pub fn schema_degradations(schema: &serde_json::Value) -> Vec<String> {
let mut findings = Vec::new();
walk_schema(schema, "$", &mut findings);
findings
}
fn walk_schema(node: &serde_json::Value, path: &str, findings: &mut Vec<String>) {
let Some(obj) = node.as_object() else {
findings.push(format!(
"{path}: schema node is not a JSON object — degraded to permissive string"
));
return;
};
for combinator in ["oneOf", "anyOf", "allOf", "not", "$ref"] {
if obj.contains_key(combinator) {
findings.push(format!(
"{path}: `{combinator}` is not representable — flattened to permissive string"
));
}
}
let type_str = match obj.get("type") {
None => {
if !obj.contains_key("properties") {
if !["oneOf", "anyOf", "allOf", "not", "$ref"]
.iter()
.any(|c| obj.contains_key(*c))
{
findings.push(format!(
"{path}: typeless node without `properties` — degraded to permissive \
string"
));
}
return;
}
"object"
}
Some(serde_json::Value::String(t)) => t.as_str(),
Some(other) => {
findings.push(format!(
"{path}: union/non-string `type` ({other}) — degraded to permissive string"
));
return;
}
};
match type_str {
"object" => {
if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
for (key, sub) in props {
walk_schema(sub, &format!("{path}.{key}"), findings);
}
}
}
"array" => {
if let Some(items) = obj.get("items") {
walk_schema(items, &format!("{path}[]"), findings);
}
}
"string" => {
if let Some(choices) = obj.get("enum").and_then(|e| e.as_array()) {
if choices.iter().any(|c| !c.is_string()) {
findings.push(format!(
"{path}: `enum` contains non-string members — enum constraint dropped, \
degraded to permissive string"
));
}
}
}
"integer" | "number" | "boolean" => {
if obj.contains_key("enum") {
findings.push(format!(
"{path}: `enum` on `{type_str}` is not representable — values ignored, \
plain `{type_str}` kept"
));
}
}
other => {
findings.push(format!(
"{path}: unrecognized `type` \"{other}\" — degraded to permissive string"
));
}
}
}
fn warn_schema_degradations(what: &str, schema: &serde_json::Value) {
for finding in schema_degradations(schema) {
tracing::warn!(
"FoundationModels constrained decoding: {what}: {finding} — the generated value is \
preserved but this part of the schema contract is not enforced"
);
}
}
pub fn generate_with_tools(
prompt: &str,
instructions: Option<&str>,
tools: &[serde_json::Value],
max_tokens: u32,
temperature: f32,
) -> Result<(String, Vec<crate::tasks::generate::ToolCall>), InferenceError> {
if !is_available() {
return Err(unavailable_error());
}
let prompt_c = CString::new(prompt)
.map_err(|e| InferenceError::InferenceFailed(format!("prompt has interior NUL: {e}")))?;
let instr_c = match instructions {
Some(s) if !s.is_empty() => Some(CString::new(s).map_err(|e| {
InferenceError::InferenceFailed(format!("instructions have interior NUL: {e}"))
})?),
_ => None,
};
for tool in tools {
let name = tool
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("<unnamed>");
if let Some(params) = tool.get("parameters") {
warn_schema_degradations(&format!("tool '{name}' parameters"), params);
}
}
let tools_json = serde_json::to_string(tools)
.map_err(|e| InferenceError::InferenceFailed(format!("tools serialization: {e}")))?;
let tools_c = CString::new(tools_json)
.map_err(|e| InferenceError::InferenceFailed(format!("tools have interior NUL: {e}")))?;
let mut out_text: *mut c_char = ptr::null_mut();
let mut out_calls: *mut c_char = ptr::null_mut();
let mut out_err: *mut c_char = ptr::null_mut();
let rc = unsafe {
car_fm_generate_with_tools(
prompt_c.as_ptr(),
instr_c.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
tools_c.as_ptr(),
max_tokens.min(i32::MAX as u32) as i32,
temperature as f64,
&mut out_text as *mut *mut c_char,
&mut out_calls as *mut *mut c_char,
&mut out_err as *mut *mut c_char,
)
};
if rc != 0 {
return Err(InferenceError::InferenceFailed(consume_swift_string(
out_err,
)));
}
let text = consume_swift_string(out_text);
let calls_json = consume_swift_string(out_calls);
let tool_calls = parse_bridge_tool_calls(&calls_json)?;
Ok((text, tool_calls))
}
fn parse_bridge_tool_calls(
calls_json: &str,
) -> Result<Vec<crate::tasks::generate::ToolCall>, InferenceError> {
if calls_json.trim().is_empty() {
return Ok(vec![]);
}
#[derive(serde::Deserialize)]
struct BridgeCall {
name: String,
#[serde(default)]
arguments: std::collections::HashMap<String, serde_json::Value>,
}
let calls: Vec<BridgeCall> = serde_json::from_str(calls_json).map_err(|e| {
InferenceError::InferenceFailed(format!(
"FoundationModels bridge returned malformed tool-call JSON: {e}"
))
})?;
Ok(calls
.into_iter()
.map(|c| crate::tasks::generate::ToolCall {
id: None,
name: c.name,
arguments: c.arguments,
})
.collect())
}
pub fn generate_structured(
prompt: &str,
instructions: Option<&str>,
schema: &serde_json::Value,
max_tokens: u32,
temperature: f32,
) -> Result<String, InferenceError> {
if !is_available() {
return Err(unavailable_error());
}
let prompt_c = CString::new(prompt)
.map_err(|e| InferenceError::InferenceFailed(format!("prompt has interior NUL: {e}")))?;
let instr_c = match instructions {
Some(s) if !s.is_empty() => Some(CString::new(s).map_err(|e| {
InferenceError::InferenceFailed(format!("instructions have interior NUL: {e}"))
})?),
_ => None,
};
warn_schema_degradations("response_format JsonSchema", schema);
let schema_json = serde_json::to_string(schema)
.map_err(|e| InferenceError::InferenceFailed(format!("schema serialization: {e}")))?;
let schema_c = CString::new(schema_json)
.map_err(|e| InferenceError::InferenceFailed(format!("schema has interior NUL: {e}")))?;
let mut out_json: *mut c_char = ptr::null_mut();
let mut out_err: *mut c_char = ptr::null_mut();
let rc = unsafe {
car_fm_generate_structured(
prompt_c.as_ptr(),
instr_c.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
schema_c.as_ptr(),
max_tokens.min(i32::MAX as u32) as i32,
temperature as f64,
&mut out_json as *mut *mut c_char,
&mut out_err as *mut *mut c_char,
)
};
if rc != 0 {
return Err(InferenceError::InferenceFailed(consume_swift_string(
out_err,
)));
}
Ok(consume_swift_string(out_json))
}
pub struct StreamCallback<'a> {
on_delta: Box<dyn FnMut(&str) -> bool + Send + 'a>,
}
impl<'a> StreamCallback<'a> {
pub fn new<F>(on_delta: F) -> Self
where
F: FnMut(&str) -> bool + Send + 'a,
{
Self {
on_delta: Box::new(on_delta),
}
}
}
extern "C" fn stream_trampoline(token: *const c_char, state: *mut c_void) -> c_int {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if state.is_null() {
return 1;
}
let cb = unsafe { &mut *(state as *mut StreamCallback) };
let s = if token.is_null() {
""
} else {
match unsafe { CStr::from_ptr(token) }.to_str() {
Ok(s) => s,
Err(_) => return 1,
}
};
if (cb.on_delta)(s) {
0 } else {
1 }
}));
match result {
Ok(rc) => rc,
Err(_) => 1, }
}
pub fn stream(
prompt: &str,
instructions: Option<&str>,
max_tokens: u32,
temperature: f32,
mut callback: StreamCallback<'_>,
) -> Result<(), InferenceError> {
if !is_available() {
return Err(unavailable_error());
}
let prompt_c = CString::new(prompt)
.map_err(|e| InferenceError::InferenceFailed(format!("prompt has interior NUL: {e}")))?;
let instr_c = match instructions {
Some(s) if !s.is_empty() => Some(CString::new(s).map_err(|e| {
InferenceError::InferenceFailed(format!("instructions have interior NUL: {e}"))
})?),
_ => None,
};
let mut out_err: *mut c_char = ptr::null_mut();
let state: *mut c_void = &mut callback as *mut StreamCallback as *mut c_void;
let rc = unsafe {
car_fm_generate_stream(
prompt_c.as_ptr(),
instr_c.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
max_tokens.min(i32::MAX as u32) as i32,
temperature as f64,
stream_trampoline,
state,
&mut out_err as *mut *mut c_char,
)
};
if rc != 0 {
return Err(InferenceError::InferenceFailed(consume_swift_string(
out_err,
)));
}
Ok(())
}
fn consume_swift_string(ptr: *mut c_char) -> String {
if ptr.is_null() {
return String::new();
}
let s = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { car_fm_free_string(ptr) };
s
}
fn unavailable_error() -> InferenceError {
InferenceError::UnsupportedMode {
mode: "apple-foundation-models",
backend: "foundation-models",
reason: "FoundationModels framework reports unavailable on this host. Requires macOS 26+ \
on Apple Silicon with Apple Intelligence enabled. Falling through to the next \
router candidate.",
}
}
#[cfg(test)]
mod tests {
use super::{parse_bridge_tool_calls, schema_degradations};
#[test]
fn parses_bridge_tool_call_wire_shape() {
let calls = parse_bridge_tool_calls(
r#"[{"name":"get_weather","arguments":{"city":"Austin","days":3}}]"#,
)
.unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].id, None);
assert_eq!(
calls[0].arguments.get("city"),
Some(&serde_json::json!("Austin"))
);
assert_eq!(calls[0].arguments.get("days"), Some(&serde_json::json!(3)));
}
#[test]
fn empty_or_missing_calls_parse_to_empty() {
assert!(parse_bridge_tool_calls("").unwrap().is_empty());
assert!(parse_bridge_tool_calls("[]").unwrap().is_empty());
}
#[test]
fn malformed_calls_json_is_an_error_not_a_silent_drop() {
assert!(parse_bridge_tool_calls("{not json").is_err());
}
#[test]
fn clean_schema_has_no_degradations() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"city": {"type": "string", "enum": ["Austin", "Boston"]},
"days": {"type": "integer"},
"tags": {"type": "array", "items": {"type": "string"}},
"nested": {"properties": {"ok": {"type": "boolean"}}}
},
"required": ["city"]
});
assert!(schema_degradations(&schema).is_empty());
}
#[test]
fn union_type_is_flagged_as_string_degradation() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"amount": {"type": ["number", "null"]}
}
});
let findings = schema_degradations(&schema);
assert_eq!(findings.len(), 1);
assert!(findings[0].contains("$.amount"), "{findings:?}");
assert!(findings[0].contains("union"), "{findings:?}");
assert!(findings[0].contains("permissive string"), "{findings:?}");
}
#[test]
fn typeless_oneof_and_ref_are_flagged() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"choice": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
"linked": {"$ref": "#/definitions/thing"},
"mystery": {"description": "no type at all"}
}
});
let findings = schema_degradations(&schema);
let all = findings.join("\n");
assert!(all.contains("$.choice") && all.contains("oneOf"), "{all}");
assert!(all.contains("$.linked") && all.contains("$ref"), "{all}");
assert!(all.contains("$.mystery") && all.contains("typeless"), "{all}");
}
#[test]
fn numeric_enum_and_unrecognized_type_are_flagged() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"level": {"type": "integer", "enum": [1, 2, 3]},
"weird": {"type": "null"},
"mixed": {"type": "string", "enum": ["a", 1]}
}
});
let findings = schema_degradations(&schema);
let all = findings.join("\n");
assert!(all.contains("$.level") && all.contains("values ignored"), "{all}");
assert!(all.contains("$.weird") && all.contains("unrecognized"), "{all}");
assert!(all.contains("$.mixed") && all.contains("non-string members"), "{all}");
assert_eq!(findings.len(), 3);
}
#[test]
fn array_items_are_walked() {
let schema = serde_json::json!({
"type": "array",
"items": {"anyOf": [{"type": "string"}]}
});
let findings = schema_degradations(&schema);
assert_eq!(findings.len(), 1);
assert!(findings[0].contains("$[]") && findings[0].contains("anyOf"));
}
}