Skip to main content

simple_agents_ffi/
lib.rs

1//! C-compatible FFI bindings for SimpleAgents.
2
3use simple_agents_core::SimpleAgentsClient;
4use simple_agents_core::SimpleAgentsClientBuilder;
5use simple_agents_providers::anthropic::AnthropicProvider;
6use simple_agents_providers::openai::OpenAIProvider;
7use simple_agents_providers::openrouter::OpenRouterProvider;
8use simple_agent_type::message::Message;
9use simple_agent_type::prelude::{
10    ApiKey, CompletionRequest, Provider, Result, SimpleAgentsError,
11};
12use std::cell::RefCell;
13use std::ffi::{CStr, CString};
14use std::os::raw::c_char;
15use std::panic::{catch_unwind, AssertUnwindSafe};
16use std::sync::{Arc, Mutex};
17
18type Runtime = tokio::runtime::Runtime;
19
20struct FfiClient {
21    runtime: Mutex<Runtime>,
22    client: SimpleAgentsClient,
23}
24
25#[repr(C)]
26pub struct SAClient {
27    inner: FfiClient,
28}
29
30thread_local! {
31    static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
32}
33
34fn set_last_error(message: impl Into<String>) {
35    LAST_ERROR.with(|slot| {
36        *slot.borrow_mut() = Some(message.into());
37    });
38}
39
40fn clear_last_error() {
41    LAST_ERROR.with(|slot| {
42        *slot.borrow_mut() = None;
43    });
44}
45
46fn take_last_error() -> Option<String> {
47    LAST_ERROR.with(|slot| slot.borrow_mut().take())
48}
49
50fn build_runtime() -> Result<Runtime> {
51    Runtime::new().map_err(|e| SimpleAgentsError::Config(format!("Failed to build runtime: {e}")))
52}
53
54fn provider_from_env(provider_name: &str) -> Result<Arc<dyn Provider>> {
55    match provider_name {
56        "openai" => Ok(Arc::new(OpenAIProvider::from_env()?)),
57        "anthropic" => Ok(Arc::new(AnthropicProvider::from_env()?)),
58        "openrouter" => Ok(Arc::new(openrouter_from_env()?)),
59        _ => Err(SimpleAgentsError::Config(format!(
60            "Unknown provider '{provider_name}'"
61        ))),
62    }
63}
64
65fn openrouter_from_env() -> Result<OpenRouterProvider> {
66    let api_key = std::env::var("OPENROUTER_API_KEY").map_err(|_| {
67        SimpleAgentsError::Config("OPENROUTER_API_KEY environment variable is required".to_string())
68    })?;
69    let api_key = ApiKey::new(api_key)?;
70    let base_url = std::env::var("OPENROUTER_API_BASE")
71        .unwrap_or_else(|_| OpenRouterProvider::DEFAULT_BASE_URL.to_string());
72    OpenRouterProvider::with_base_url(api_key, base_url)
73}
74
75unsafe fn cstr_to_string(ptr: *const c_char, field: &str) -> Result<String> {
76    if ptr.is_null() {
77        return Err(SimpleAgentsError::Config(format!("{field} cannot be null")));
78    }
79
80    let c_str = CStr::from_ptr(ptr);
81    let value = c_str
82        .to_str()
83        .map_err(|_| SimpleAgentsError::Config(format!("{field} must be valid UTF-8")))?;
84    if value.is_empty() {
85        return Err(SimpleAgentsError::Config(format!(
86            "{field} cannot be empty"
87        )));
88    }
89
90    Ok(value.to_string())
91}
92
93fn build_client(provider: Arc<dyn Provider>) -> Result<SimpleAgentsClient> {
94    SimpleAgentsClientBuilder::new()
95        .with_provider(provider)
96        .build()
97}
98
99fn build_request(
100    model: &str,
101    prompt: &str,
102    max_tokens: i32,
103    temperature: f32,
104) -> Result<CompletionRequest> {
105    let mut builder = CompletionRequest::builder()
106        .model(model)
107        .message(Message::user(prompt));
108
109    if max_tokens > 0 {
110        builder = builder.max_tokens(max_tokens as u32);
111    }
112
113    if temperature >= 0.0 {
114        builder = builder.temperature(temperature);
115    }
116
117    builder.build()
118}
119
120fn ffi_result_string(result: Result<String>) -> *mut c_char {
121    match result {
122        Ok(value) => match CString::new(value) {
123            Ok(c_string) => {
124                clear_last_error();
125                c_string.into_raw()
126            }
127            Err(_) => {
128                set_last_error("Response contained an interior null byte".to_string());
129                std::ptr::null_mut()
130            }
131        },
132        Err(error) => {
133            set_last_error(error.to_string());
134            std::ptr::null_mut()
135        }
136    }
137}
138
139fn ffi_guard<T>(action: impl FnOnce() -> Result<T>) -> *mut c_char
140where
141    T: Into<String>,
142{
143    let result = catch_unwind(AssertUnwindSafe(action));
144    match result {
145        Ok(inner) => ffi_result_string(inner.map(Into::into)),
146        Err(_) => {
147            set_last_error("Panic occurred in FFI call".to_string());
148            std::ptr::null_mut()
149        }
150    }
151}
152
153/// Create a client from environment variables for a provider.
154///
155/// `provider_name` must be one of: "openai", "anthropic", "openrouter".
156///
157/// # Safety
158///
159/// The `provider_name` pointer must be a valid null-terminated C string or null.
160/// The returned pointer must be freed with `sa_client_free`.
161#[no_mangle]
162pub unsafe extern "C" fn sa_client_new_from_env(provider_name: *const c_char) -> *mut SAClient {
163    let result = catch_unwind(AssertUnwindSafe(|| -> Result<Box<SAClient>> {
164        let provider = cstr_to_string(provider_name, "provider_name")?;
165        let provider = provider_from_env(&provider)?;
166        let client = build_client(provider)?;
167        let runtime = build_runtime()?;
168
169        Ok(Box::new(SAClient {
170            inner: FfiClient {
171                runtime: Mutex::new(runtime),
172                client,
173            },
174        }))
175    }));
176
177    match result {
178        Ok(Ok(client)) => {
179            clear_last_error();
180            Box::into_raw(client)
181        }
182        Ok(Err(error)) => {
183            set_last_error(error.to_string());
184            std::ptr::null_mut()
185        }
186        Err(_) => {
187            set_last_error("Panic occurred in sa_client_new_from_env".to_string());
188            std::ptr::null_mut()
189        }
190    }
191}
192
193/// Free a client created by `sa_client_new_from_env`.
194///
195/// # Safety
196///
197/// The `client` pointer must be null or a valid pointer returned by `sa_client_new_from_env`.
198/// After calling this function, the pointer is no longer valid and must not be used.
199#[no_mangle]
200pub unsafe extern "C" fn sa_client_free(client: *mut SAClient) {
201    if client.is_null() {
202        return;
203    }
204
205    drop(Box::from_raw(client));
206}
207
208/// Execute a completion request with a single user prompt.
209///
210/// Use `max_tokens <= 0` to omit, and `temperature < 0.0` to omit.
211///
212/// # Safety
213///
214/// The `client` pointer must be a valid pointer returned by `sa_client_new_from_env`.
215/// The `model` and `prompt` pointers must be valid null-terminated C strings.
216/// The returned pointer must be freed with `sa_string_free`.
217#[no_mangle]
218pub unsafe extern "C" fn sa_complete(
219    client: *mut SAClient,
220    model: *const c_char,
221    prompt: *const c_char,
222    max_tokens: i32,
223    temperature: f32,
224) -> *mut c_char {
225    if client.is_null() {
226        set_last_error("client cannot be null".to_string());
227        return std::ptr::null_mut();
228    }
229
230    ffi_guard(|| {
231        let model = cstr_to_string(model, "model")?;
232        let prompt = cstr_to_string(prompt, "prompt")?;
233        let request = build_request(&model, &prompt, max_tokens, temperature)?;
234
235        let client = &(*client).inner;
236        let runtime = client
237            .runtime
238            .lock()
239            .map_err(|_| SimpleAgentsError::Config("runtime lock poisoned".to_string()))?;
240        let response = runtime.block_on(client.client.complete(&request))?;
241
242        Ok(response.content().unwrap_or_default().to_string())
243    })
244}
245
246/// Get the last error message for the current thread.
247///
248/// Returns null if there is no error. Caller must free the string.
249#[no_mangle]
250pub extern "C" fn sa_last_error_message() -> *mut c_char {
251    match take_last_error() {
252        Some(message) => match CString::new(message) {
253            Ok(c_string) => c_string.into_raw(),
254            Err(_) => std::ptr::null_mut(),
255        },
256        None => std::ptr::null_mut(),
257    }
258}
259
260/// Free a string returned by SimpleAgents FFI.
261///
262/// # Safety
263///
264/// The `value` pointer must be null or a valid pointer returned by a SimpleAgents FFI function.
265/// After calling this function, the pointer is no longer valid and must not be used.
266#[no_mangle]
267pub unsafe extern "C" fn sa_string_free(value: *mut c_char) {
268    if value.is_null() {
269        return;
270    }
271
272    drop(CString::from_raw(value));
273}