foundation_models/async_api.rs
1//! Executor-agnostic async API for `FoundationModels` (Tier 1).
2//!
3//! Enabled with the `async` Cargo feature. Works with any async runtime
4//! (Tokio, async-std, smol, pollster, …) because it uses only `std` types
5//! internally.
6//!
7//! ## Wrapped Apple APIs
8//!
9//! | Rust type | Apple API | Notes |
10//! |-----------|-----------|-------|
11//! | [`AsyncSession::respond`] | `LanguageModelSession.respond(to:)` | Returns `SessionResponse<String>` |
12//! | [`AsyncSession::respond_generating`] | `LanguageModelSession.respond(to:generating:)` | Returns `SessionResponse<GeneratedContent>` |
13//! | [`AsyncAdapter::from_name`] | `SystemLanguageModel.Adapter init(name:)` | Returns `Adapter` |
14//! | [`AsyncAdapter::compatibility`] | `SystemLanguageModel.Adapter.compatibility(for:)` | Returns `Vec<String>` |
15//!
16//! ## Tier 2 note
17//!
18//! `LanguageModelSession.streamResponse(to:)` is an `AsyncSequence` — a
19//! multi-fire stream, not a one-shot future. It is deferred to **Tier 2**
20//! (stream pattern). Use [`crate::LanguageModelSession::stream`] for
21//! synchronous streaming in the meantime.
22//!
23//! ## Example
24//!
25//! ```rust,no_run
26//! use foundation_models::{LanguageModelSession, SystemLanguageModel};
27//! use foundation_models::async_api::AsyncSession;
28//!
29//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
30//! if !SystemLanguageModel::is_available() {
31//! eprintln!("SKIP: FoundationModels unavailable");
32//! return Ok(());
33//! }
34//! pollster::block_on(async {
35//! let session = LanguageModelSession::new();
36//! let async_session = AsyncSession::new(&session);
37//! let reply = async_session.respond("Name three Norse gods.")?.await?;
38//! println!("{}", reply.content);
39//! Ok::<(), Box<dyn std::error::Error>>(())
40//! })
41//! # }
42//! ```
43
44use std::ffi::{c_void, CStr, CString};
45use std::future::Future;
46use std::pin::Pin;
47use std::task::{Context, Poll};
48
49use doom_fish_utils::completion::{error_from_cstr, AsyncCompletion, AsyncCompletionFuture};
50use serde::Deserialize;
51
52use crate::content::{BridgeGeneratedContent, GeneratedContent};
53use crate::error::FMError;
54use crate::ffi;
55use crate::generation::GenerationOptions;
56use crate::model::Adapter;
57use crate::prompt::{Prompt, ToPrompt};
58use crate::schema::GenerationSchema;
59use crate::session::{decode_bridge_text_response, SessionResponse};
60use crate::transcript::Transcript;
61
62// ============================================================================
63// Private bridge structs – mirror the JSON shapes emitted by SessionExtras.swift
64// ============================================================================
65
66#[derive(Debug, Deserialize)]
67struct AsyncBridgeStructuredResponse {
68 content: BridgeGeneratedContent,
69 #[serde(rename = "rawContent")]
70 raw_content: BridgeGeneratedContent,
71 #[serde(rename = "transcriptJSON")]
72 transcript_json: String,
73}
74
75// ============================================================================
76// Opaque pointer newtype – needed so AsyncCompletion<OpaquePtr> is Send
77// ============================================================================
78
79/// Thin Send-able wrapper around a raw opaque pointer returned by Swift.
80///
81/// # Safety
82///
83/// The pointer is a retained `AdapterBox` produced by
84/// `Unmanaged.passRetained(…).toOpaque()` on the Swift side. We only
85/// ever pass it back to `fm_object_release`; we never dereference it in
86/// Rust. Swift's reference counting is thread-safe, so `Send` is valid.
87struct OpaquePtr(*mut c_void);
88// SAFETY: See doc comment above.
89unsafe impl Send for OpaquePtr {}
90
91// ============================================================================
92// Callback: `FmRespondCallback` (4-arg) → AsyncCompletion<String>
93//
94// Reuses the existing `fm_session_respond_request_json` FFI which already
95// runs `try await session.respond(…)` inside a Swift Task.
96// ============================================================================
97
98/// Async respond callback. Matches `ffi::FmRespondCallback`.
99///
100/// On success copies the JSON response to an owned `String` and completes
101/// the `AsyncCompletion`. On failure maps the status + error to an
102/// `FMError` message string (the Future newtypes re-map that to `FMError`).
103///
104/// # Safety
105///
106/// `ctx` must be a valid `AsyncCompletion<String>` context pointer.
107/// `response` and `error` are nullable C strings owned by the Swift bridge.
108unsafe extern "C" fn respond_async_cb(
109 ctx: *mut c_void,
110 response: *mut std::ffi::c_char,
111 error: *mut std::ffi::c_char,
112 status: i32,
113) {
114 if status == ffi::status::OK && !response.is_null() {
115 let s = unsafe { CStr::from_ptr(response) }
116 .to_string_lossy()
117 .into_owned();
118 unsafe { ffi::fm_string_free(response) };
119 unsafe { AsyncCompletion::complete_ok(ctx, s) };
120 } else {
121 // Re-use the existing from_swift error mapper; convert FMError to String
122 // so we can store it in AsyncCompletion<String>.
123 let fm_err = crate::error::from_swift(status, error);
124 unsafe { AsyncCompletion::<String>::complete_err(ctx, fm_err.to_string()) };
125 }
126}
127
128// ============================================================================
129// Callback: 3-arg async callback → AsyncCompletion<OpaquePtr>
130//
131// Used by fm_adapter_create_from_name_async.
132// ============================================================================
133
134/// # Safety
135///
136/// `ctx` must be a valid `AsyncCompletion<OpaquePtr>` context pointer.
137unsafe extern "C" fn adapter_init_async_cb(
138 result: *mut c_void,
139 error: *const std::ffi::c_char,
140 ctx: *mut c_void,
141) {
142 if !error.is_null() {
143 let msg = unsafe { error_from_cstr(error) };
144 unsafe { AsyncCompletion::<OpaquePtr>::complete_err(ctx, msg) };
145 } else if !result.is_null() {
146 unsafe { AsyncCompletion::complete_ok(ctx, OpaquePtr(result)) };
147 } else {
148 unsafe {
149 AsyncCompletion::<OpaquePtr>::complete_err(ctx, "null adapter pointer".into())
150 };
151 }
152}
153
154// ============================================================================
155// Callback: 3-arg async callback → AsyncCompletion<String>
156//
157// Used by fm_adapter_compatibility_async. The result pointer is a strdup'd
158// JSON string; we copy it and free it.
159// ============================================================================
160
161/// # Safety
162///
163/// `ctx` must be a valid `AsyncCompletion<String>` context pointer.
164/// `result` (when non-null) must be a heap-allocated C string freed with
165/// `fm_string_free`.
166unsafe extern "C" fn adapter_compat_async_cb(
167 result: *mut c_void,
168 error: *const std::ffi::c_char,
169 ctx: *mut c_void,
170) {
171 if !error.is_null() {
172 let msg = unsafe { error_from_cstr(error) };
173 unsafe { AsyncCompletion::<String>::complete_err(ctx, msg) };
174 } else if !result.is_null() {
175 let s = unsafe { CStr::from_ptr(result.cast::<std::ffi::c_char>()) }
176 .to_string_lossy()
177 .into_owned();
178 // Free the strdup'd JSON string allocated by the Swift bridge.
179 unsafe { ffi::fm_string_free(result.cast::<std::ffi::c_char>()) };
180 unsafe { AsyncCompletion::complete_ok(ctx, s) };
181 } else {
182 unsafe {
183 AsyncCompletion::<String>::complete_err(ctx, "null compatibility result".into())
184 };
185 }
186}
187
188// ============================================================================
189// RespondFuture — LanguageModelSession.respond(to:)
190// ============================================================================
191
192/// Future returned by [`AsyncSession::respond`].
193///
194/// Resolves to `Result<SessionResponse<String>, FMError>`.
195pub struct RespondFuture {
196 inner: AsyncCompletionFuture<String>,
197}
198
199impl std::fmt::Debug for RespondFuture {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.debug_struct("RespondFuture").finish_non_exhaustive()
202 }
203}
204
205impl Future for RespondFuture {
206 type Output = Result<SessionResponse<String>, FMError>;
207
208 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
209 Pin::new(&mut self.inner).poll(cx).map(|r| {
210 r.map_err(|msg| FMError::Unknown {
211 code: ffi::status::UNKNOWN,
212 message: msg,
213 })
214 .and_then(|json| decode_bridge_text_response(&json))
215 })
216 }
217}
218
219// ============================================================================
220// RespondGeneratingFuture — LanguageModelSession.respond(to:generating:)
221// ============================================================================
222
223/// Future returned by [`AsyncSession::respond_generating`].
224///
225/// Resolves to `Result<SessionResponse<GeneratedContent>, FMError>`.
226pub struct RespondGeneratingFuture {
227 inner: AsyncCompletionFuture<String>,
228}
229
230impl std::fmt::Debug for RespondGeneratingFuture {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 f.debug_struct("RespondGeneratingFuture")
233 .finish_non_exhaustive()
234 }
235}
236
237impl Future for RespondGeneratingFuture {
238 type Output = Result<SessionResponse<GeneratedContent>, FMError>;
239
240 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
241 Pin::new(&mut self.inner).poll(cx).map(|r| {
242 r.map_err(|msg| FMError::Unknown {
243 code: ffi::status::UNKNOWN,
244 message: msg,
245 })
246 .and_then(|json| {
247 let response: AsyncBridgeStructuredResponse =
248 serde_json::from_str(&json)
249 .map_err(|e| FMError::DecodingFailure(e.to_string()))?;
250 Ok(SessionResponse {
251 content: GeneratedContent::from_bridge_payload(response.content, true)?,
252 raw_content: GeneratedContent::from_bridge_payload(
253 response.raw_content,
254 true,
255 )?,
256 transcript: Transcript::from_json_str(&response.transcript_json)?,
257 })
258 })
259 })
260 }
261}
262
263// ============================================================================
264// AdapterInitFuture — SystemLanguageModel.Adapter init(name:)
265// ============================================================================
266
267/// Future returned by [`AsyncAdapter::from_name`].
268///
269/// Resolves to `Result<Adapter, FMError>`.
270pub struct AdapterInitFuture {
271 inner: AsyncCompletionFuture<OpaquePtr>,
272}
273
274impl std::fmt::Debug for AdapterInitFuture {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 f.debug_struct("AdapterInitFuture").finish_non_exhaustive()
277 }
278}
279
280impl Future for AdapterInitFuture {
281 type Output = Result<Adapter, FMError>;
282
283 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
284 Pin::new(&mut self.inner).poll(cx).map(|r| {
285 r.map_err(FMError::AdapterInvalidName)
286 .map(|OpaquePtr(ptr)| Adapter { ptr })
287 })
288 }
289}
290
291// ============================================================================
292// AdapterCompatibilityFuture — SystemLanguageModel.Adapter.compatibility(for:)
293// ============================================================================
294
295/// Future returned by [`AsyncAdapter::compatibility`].
296///
297/// Resolves to `Result<Vec<String>, FMError>`.
298pub struct AdapterCompatibilityFuture {
299 inner: AsyncCompletionFuture<String>,
300}
301
302impl std::fmt::Debug for AdapterCompatibilityFuture {
303 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 f.debug_struct("AdapterCompatibilityFuture")
305 .finish_non_exhaustive()
306 }
307}
308
309impl Future for AdapterCompatibilityFuture {
310 type Output = Result<Vec<String>, FMError>;
311
312 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
313 Pin::new(&mut self.inner).poll(cx).map(|r| {
314 r.map_err(FMError::AdapterCompatibleNotFound)
315 .and_then(|json| {
316 serde_json::from_str::<Vec<String>>(&json)
317 .map_err(|e| FMError::DecodingFailure(e.to_string()))
318 })
319 })
320 }
321}
322
323// ============================================================================
324// AsyncSession — async wrapper around LanguageModelSession
325// ============================================================================
326
327/// Async wrapper around [`crate::LanguageModelSession`].
328///
329/// Borrows the session for its lifetime; the session itself must outlive all
330/// in-flight futures.
331///
332/// # Examples
333///
334/// ```rust,no_run
335/// use foundation_models::{LanguageModelSession, SystemLanguageModel};
336/// use foundation_models::async_api::AsyncSession;
337///
338/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
339/// if !SystemLanguageModel::is_available() { return Ok(()); }
340/// pollster::block_on(async {
341/// let session = LanguageModelSession::new();
342/// let reply = AsyncSession::new(&session).respond("Hi!")?.await?;
343/// println!("{}", reply.content);
344/// Ok::<(), Box<dyn std::error::Error>>(())
345/// })
346/// # }
347/// ```
348pub struct AsyncSession<'s> {
349 session: &'s crate::session::LanguageModelSession,
350}
351
352impl<'s> AsyncSession<'s> {
353 /// Wrap a [`crate::LanguageModelSession`] for async use.
354 #[must_use]
355 pub fn new(session: &'s crate::session::LanguageModelSession) -> Self {
356 Self { session }
357 }
358
359 /// Async version of `LanguageModelSession.respond(to:)`.
360 ///
361 /// Corresponds to the Swift `async throws` method
362 /// `LanguageModelSession.respond(to:)`.
363 ///
364 /// # Errors
365 ///
366 /// Returns an [`FMError`] if the model is unavailable or generation fails.
367 pub fn respond(&self, prompt: impl ToPrompt) -> Result<RespondFuture, FMError> {
368 let prompt = prompt.to_prompt()?;
369 let payload = build_text_request_json(&prompt, GenerationOptions::new())?;
370 let session_ptr = self.session.as_ptr();
371 let (future, ctx) = AsyncCompletion::create();
372 unsafe {
373 ffi::fm_session_respond_request_json(
374 session_ptr,
375 payload.as_ptr(),
376 ctx,
377 respond_async_cb,
378 );
379 }
380 Ok(RespondFuture { inner: future })
381 }
382
383 /// Async version of `LanguageModelSession.respond(to:)` with [`GenerationOptions`].
384 ///
385 /// # Errors
386 ///
387 /// Returns an [`FMError`] if the model is unavailable or generation fails.
388 pub fn respond_with_options(
389 &self,
390 prompt: impl ToPrompt,
391 options: GenerationOptions,
392 ) -> Result<RespondFuture, FMError> {
393 let prompt = prompt.to_prompt()?;
394 let payload = build_text_request_json(&prompt, options)?;
395 let session_ptr = self.session.as_ptr();
396 let (future, ctx) = AsyncCompletion::create();
397 unsafe {
398 ffi::fm_session_respond_request_json(
399 session_ptr,
400 payload.as_ptr(),
401 ctx,
402 respond_async_cb,
403 );
404 }
405 Ok(RespondFuture { inner: future })
406 }
407
408 /// Async version of `LanguageModelSession.respond(to:generating:)`.
409 ///
410 /// Generates a structured `GeneratedContent` response according to
411 /// `schema`. Corresponds to the Swift `async throws` method
412 /// `LanguageModelSession.respond(to:generating:)`.
413 ///
414 /// # Errors
415 ///
416 /// Returns an [`FMError`] if the model is unavailable or generation fails.
417 pub fn respond_generating(
418 &self,
419 prompt: impl ToPrompt,
420 schema: &GenerationSchema,
421 include_schema_in_prompt: bool,
422 options: GenerationOptions,
423 ) -> Result<RespondGeneratingFuture, FMError> {
424 let prompt = prompt.to_prompt()?;
425 let payload =
426 build_structured_request_json(&prompt, options, schema, include_schema_in_prompt)?;
427 let session_ptr = self.session.as_ptr();
428 let (future, ctx) = AsyncCompletion::create();
429 unsafe {
430 ffi::fm_session_respond_request_json(
431 session_ptr,
432 payload.as_ptr(),
433 ctx,
434 respond_async_cb,
435 );
436 }
437 Ok(RespondGeneratingFuture { inner: future })
438 }
439}
440
441impl std::fmt::Debug for AsyncSession<'_> {
442 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443 f.debug_struct("AsyncSession").finish_non_exhaustive()
444 }
445}
446
447// ============================================================================
448// AsyncAdapter — async adapter lifecycle
449// ============================================================================
450
451/// Namespace for async [`Adapter`] operations.
452///
453/// # Examples
454///
455/// ```rust,no_run
456/// use foundation_models::async_api::AsyncAdapter;
457///
458/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
459/// pollster::block_on(async {
460/// let ids = AsyncAdapter::compatibility("com.example.MyAdapter")?.await?;
461/// println!("compatible: {ids:?}");
462/// Ok::<(), Box<dyn std::error::Error>>(())
463/// })
464/// # }
465/// ```
466pub struct AsyncAdapter;
467
468impl AsyncAdapter {
469 /// Async version of `SystemLanguageModel.Adapter init(name:)`.
470 ///
471 /// Loads the named adapter asynchronously, returning a ready-to-use
472 /// [`Adapter`] handle.
473 ///
474 /// # Errors
475 ///
476 /// Returns an [`FMError::AdapterInvalidName`] if the adapter is not found
477 /// or the name contains a NUL byte.
478 pub fn from_name(name: &str) -> Result<AdapterInitFuture, FMError> {
479 let cname = CString::new(name)
480 .map_err(|e| FMError::InvalidArgument(format!("NUL byte in adapter name: {e}")))?;
481 let (future, ctx) = AsyncCompletion::create();
482 unsafe {
483 ffi::fm_adapter_create_from_name_async(cname.as_ptr(), ctx, adapter_init_async_cb);
484 }
485 Ok(AdapterInitFuture { inner: future })
486 }
487
488 /// Async version of `SystemLanguageModel.Adapter.compatibility(for:)`.
489 ///
490 /// Returns the list of compatible adapter identifiers for the given
491 /// logical adapter name.
492 ///
493 /// # Errors
494 ///
495 /// Returns an [`FMError::AdapterCompatibleNotFound`] on failure.
496 pub fn compatibility(name: &str) -> Result<AdapterCompatibilityFuture, FMError> {
497 let cname = CString::new(name)
498 .map_err(|e| FMError::InvalidArgument(format!("NUL byte in adapter name: {e}")))?;
499 let (future, ctx) = AsyncCompletion::create();
500 unsafe {
501 ffi::fm_adapter_compatibility_async(cname.as_ptr(), ctx, adapter_compat_async_cb);
502 }
503 Ok(AdapterCompatibilityFuture { inner: future })
504 }
505}
506
507// ============================================================================
508// Internal JSON request builders
509// ============================================================================
510
511fn build_text_request_json(
512 prompt: &Prompt,
513 options: GenerationOptions,
514) -> Result<CString, FMError> {
515 build_request_json_inner(prompt, options, None, true)
516}
517
518fn build_structured_request_json(
519 prompt: &Prompt,
520 options: GenerationOptions,
521 schema: &GenerationSchema,
522 include_schema_in_prompt: bool,
523) -> Result<CString, FMError> {
524 build_request_json_inner(prompt, options, Some(schema), include_schema_in_prompt)
525}
526
527fn build_request_json_inner(
528 prompt: &Prompt,
529 options: GenerationOptions,
530 schema: Option<&GenerationSchema>,
531 include_schema_in_prompt: bool,
532) -> Result<CString, FMError> {
533 use crate::generation::SamplingMode;
534 use serde_json::json;
535
536 let sampling = match options.sampling() {
537 SamplingMode::Default => json!({ "mode": "default" }),
538 SamplingMode::Greedy => json!({ "mode": "greedy" }),
539 SamplingMode::TopK(k) => json!({
540 "mode": "top_k",
541 "topK": k,
542 "seed": options.sampling_seed(),
543 }),
544 SamplingMode::TopP(p) => json!({
545 "mode": "top_p",
546 "topP": p,
547 "seed": options.sampling_seed(),
548 }),
549 };
550 let payload = serde_json::to_string(&json!({
551 "prompt": prompt.to_bridge_value(),
552 "options": {
553 "temperature": options.temperature(),
554 "maximumResponseTokens": options.maximum_response_tokens(),
555 "sampling": sampling,
556 },
557 "schemaJSON": schema.map(GenerationSchema::json_schema),
558 "includeSchemaInPrompt": include_schema_in_prompt,
559 }))
560 .map_err(|e| FMError::InvalidArgument(format!("request not JSON-serializable: {e}")))?;
561 CString::new(payload)
562 .map_err(|e| FMError::InvalidArgument(format!("request JSON contains NUL: {e}")))
563}