1pub mod declaration;
32
33use crate::runtime::effects::{self, NativeError};
34use std::sync::Arc;
35
36use wasmtime::{FuncType, HeapType, Linker, RefType, StructType, Val, ValType};
37
38use crate::runtime::StoreData;
39use crate::runtime::call_log::{ModelUsage, Payload, Side, record_payload, record_usage};
40use crate::runtime::decision::CallTicket;
41use crate::runtime::embedding::{
42 EmbeddingBatch, EmbeddingBoundKind, EmbeddingError, EmbeddingLimits, EmbeddingMalformedReason,
43 EmbeddingModel, EmbeddingProvider, EmbeddingTokenBudget, Purpose, SubBatchSettlement,
44};
45use crate::runtime::fuel;
46use crate::runtime::host::{
47 abi_arg, abi_result, fatal_host_error, quota_exceeded_error, range_error, read_string_arg,
48 register_host_fn, register_host_value_async, type_error, write_boxed_number_struct,
49 write_submilli_array_struct_precharged, write_submilli_string_struct,
50 write_submilli_uint8array_struct, write_uint8_array_precharged,
51};
52use crate::runtime::intrinsic_types::build_intrinsic_types;
53use crate::stdlib::abi::{
54 self, HostView, backing_struct, f64_field, install_field_getters, nullable_object_field,
55 raw_bytes_field, string_field,
56};
57use crate::stdlib::shared::{
58 audit_quota_denial, check_security_call, filters_candidate, mark_filtered, optional_number,
59 preflight_models, sanitize_description,
60};
61
62pub use crate::runtime::EMBEDDING_MODULE_NAME as MODULE_NAME;
63pub use declaration::package_declaration;
64
65pub const CAPABILITY: &str = "embedding.embed";
67
68const E_VECTORS: usize = 1;
70const E_COUNT: usize = 2;
71const E_DIMENSIONS: usize = 3;
72const E_IDENTITY: usize = 4;
73const E_MODEL: usize = 5;
74const E_INPUT_TOKENS: usize = 6;
75
76const M_NAME: usize = 1;
78const M_DESCRIPTION: usize = 2;
79const M_DIMENSIONS: usize = 3;
80const M_MAX_INPUT_TOKENS: usize = 4;
81const M_MAX_INPUT_BYTES: usize = 5;
82const M_IDENTITY: usize = 6;
83
84#[derive(serde::Serialize)]
85struct EmbedRequest {
86 model: String,
87 texts: Vec<String>,
88 purpose: Purpose,
89}
90
91pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
92 let engine = linker.engine().clone();
93 let intr = build_intrinsic_types(&engine)?;
94 let string = ValType::Ref(RefType::new(
95 false,
96 HeapType::ConcreteStruct(intr.string.clone()),
97 ));
98 let array = ValType::Ref(RefType::new(
99 false,
100 HeapType::ConcreteStruct(intr.array.clone()),
101 ));
102 let uint8 = ValType::Ref(RefType::new(
103 false,
104 HeapType::ConcreteStruct(intr.uint8_array.clone()),
105 ));
106 let nullable_object = ValType::Ref(RefType::new(
109 true,
110 HeapType::ConcreteStruct(intr.object.clone()),
111 ));
112 let receiver = ValType::Ref(RefType::new(
113 false,
114 HeapType::ConcreteStruct(intr.object.clone()),
115 ));
116
117 register_host_value_async(
118 linker,
119 MODULE_NAME,
120 crate::mangle::package_symbol(MODULE_NAME, "embed"),
121 FuncType::new(
122 &engine,
123 [string.clone(), array.clone(), string.clone()],
124 [nullable_object.clone()],
125 ),
126 |caller, params| {
127 Box::pin(async move {
128 let model =
129 read_string_arg(&mut *caller, abi_arg(params, 0)?, "embedding.embed (model)")?;
130 let purpose = read_string_arg(
131 &mut *caller,
132 abi_arg(params, 2)?,
133 "embedding.embed (purpose)",
134 )?;
135 embed(caller, &model, abi_arg(params, 1)?, &purpose).await
136 })
137 },
138 )?;
139
140 register_host_value_async(
141 linker,
142 MODULE_NAME,
143 crate::mangle::package_symbol(MODULE_NAME, "models"),
144 FuncType::new(&engine, [], [array.clone()]),
145 |caller, _params| Box::pin(async move { models(caller).await }),
146 )?;
147
148 for view in HOST_VIEWS {
149 install_field_getters(linker, &engine, &receiver, view)?;
150 }
151 install_methods(linker, &engine, receiver, array, uint8)
152}
153
154const EMBEDDINGS_VIEW: HostView = HostView {
155 module: MODULE_NAME,
156 interface: "Embeddings",
157 backing: embeddings_backing_struct,
158 properties: &[
159 ("count", E_COUNT),
160 ("dimensions", E_DIMENSIONS),
161 ("identity", E_IDENTITY),
162 ("model", E_MODEL),
163 ("inputTokens", E_INPUT_TOKENS),
164 ],
165};
166
167const EMBEDDING_MODEL_VIEW: HostView = HostView {
168 module: MODULE_NAME,
169 interface: "EmbeddingModel",
170 backing: model_backing_struct,
171 properties: &[
172 ("name", M_NAME),
173 ("description", M_DESCRIPTION),
174 ("dimensions", M_DIMENSIONS),
175 ("maxInputTokens", M_MAX_INPUT_TOKENS),
176 ("maxInputBytes", M_MAX_INPUT_BYTES),
177 ("identity", M_IDENTITY),
178 ],
179};
180
181pub(crate) const HOST_VIEWS: &[&HostView] = &[&EMBEDDINGS_VIEW, &EMBEDDING_MODEL_VIEW];
183
184fn install_methods(
185 linker: &mut Linker<StoreData>,
186 engine: &wasmtime::Engine,
187 receiver: ValType,
188 array: ValType,
189 uint8: ValType,
190) -> wasmtime::Result<()> {
191 let embeddings_key = crate::mangle::package_symbol(MODULE_NAME, "Embeddings");
192
193 register_host_fn(
194 linker,
195 MODULE_NAME,
196 crate::mangle::extend(&embeddings_key, "vector"),
197 FuncType::new(engine, [receiver.clone(), ValType::F64], [array]),
198 |caller, params, results| {
199 let row = read_row(
200 caller,
201 abi_arg(params, 0)?,
202 abi_arg(params, 1)?,
203 "embedding.vector",
204 )?;
205 *abi_result(results, 0)? = build_number_array(caller, &row)?;
206 Ok(())
207 },
208 )?;
209
210 register_host_fn(
211 linker,
212 MODULE_NAME,
213 crate::mangle::extend(&embeddings_key, "bytes"),
214 FuncType::new(engine, [receiver, ValType::F64], [uint8]),
215 |caller, params, results| {
216 let row = read_row(
217 caller,
218 abi_arg(params, 0)?,
219 abi_arg(params, 1)?,
220 "embedding.bytes",
221 )?;
222 *abi_result(results, 0)? = Val::AnyRef(Some(
223 write_submilli_uint8array_struct(caller, &row)?.to_anyref(),
224 ));
225 Ok(())
226 },
227 )
228}
229
230async fn embed(
244 caller: &mut wasmtime::Caller<'_, StoreData>,
245 model: &str,
246 texts: &Val,
247 purpose: &str,
248) -> wasmtime::Result<Val> {
249 let elements = crate::runtime::prelude::collection::read_array_vals(caller, texts)?;
250 let ticket = gate(caller, model, elements.len())?;
251
252 let budget = execution_budget(caller);
253 let limits = budget.limits();
254 let texts = read_texts(caller, model, &elements, &limits)?;
255 record_payload(&*caller, ticket, Side::Request, || {
258 let body = serde_json::to_vec(&texts).unwrap_or_default();
259 let bytes: usize = texts.iter().map(String::len).sum();
260 Payload::meta(serde_json::json!({
261 "op": "embed",
262 "model": model,
263 "purpose": purpose,
264 "count": texts.len(),
265 }))
266 .with_owned_body(body)
267 .with_size(bytes as u64)
268 });
269 let purpose = parse_purpose(model, purpose)?;
270
271 let request = EmbedRequest {
272 model: model.to_owned(),
273 texts,
274 purpose,
275 };
276 let mut response = effects::execute(
277 caller,
278 "embedding.embed",
279 request,
280 move |caller, _, request| Box::pin(call_provider(caller, ticket, request)),
281 )
282 .await?;
283 if let Some(settlement) = &mut response.settlement {
285 settlement.charge();
286 } else {
287 budget.restore_spend(response.used, response.held, response.requests);
288 }
289 let batch = response.result.map_err(NativeError::into_error)?;
290 build_embeddings(caller, batch)
291}
292
293#[derive(serde::Serialize, serde::Deserialize)]
294struct EmbeddingResponse {
295 result: Result<EmbeddingBatch, NativeError>,
296 used: u64,
297 held: u64,
298 requests: u64,
299 #[serde(skip)]
300 settlement: Option<EmbeddingSettlement>,
301}
302
303struct EmbeddingSettlement {
306 budget: Option<Arc<EmbeddingTokenBudget>>,
307 reserved: u64,
308 settlements: Vec<SubBatchSettlement>,
309}
310
311impl EmbeddingSettlement {
312 fn charge(&mut self) {
313 if let Some(budget) = self.budget.take() {
314 budget.settle(self.reserved, &self.settlements);
315 }
316 }
317}
318
319impl Drop for EmbeddingSettlement {
320 fn drop(&mut self) {
321 self.charge();
322 }
323}
324
325async fn call_provider(
326 caller: &mut wasmtime::Caller<'_, StoreData>,
327 ticket: Option<CallTicket>,
328 request: EmbedRequest,
329) -> wasmtime::Result<EmbeddingResponse> {
330 let model = request.model.as_str();
331 let texts = request.texts;
332 let purpose = request.purpose;
333 let budget = execution_budget(caller);
334 let provider = provider(caller, model)?;
335 check_inputs(caller, &*provider, model, &texts).await?;
336
337 let sent: usize = texts.iter().map(String::len).sum();
338 fuel::charge(&mut *caller, fuel::IO, sent as u64)?;
339
340 let estimate = provider.estimate_tokens(model, &texts);
341 budget
342 .reserve(model, estimate)
343 .map_err(|error| quota_throw(caller, ticket, model, error))?;
344 let requests_before = budget.requests();
345 let dispatched = provider.embed(model, &texts, purpose, &budget).await;
346 let requests = budget.requests().saturating_sub(requests_before);
347 let settlements = match &dispatched {
348 Ok(batch) => batch.settlements(),
349 Err(error) => error.settlements(),
350 };
351 let (used, held) = settlements.iter().fold((0u64, 0u64), |(used, held), part| {
352 (
353 used.saturating_add(part.reported)
354 .saturating_add(part.indeterminate),
355 held.saturating_add(part.indeterminate),
356 )
357 });
358 let settlement = EmbeddingSettlement {
359 budget: Some(budget),
360 reserved: estimate,
361 settlements: settlements.to_vec(),
362 };
363 let result = match dispatched {
364 Ok(batch) if batch.count() != texts.len() => Err(throw(EmbeddingError::Malformed {
365 alias: model.to_string(),
366 reason: EmbeddingMalformedReason::CountMismatch,
367 settlements: Vec::new(),
368 })),
369 Ok(batch) => {
370 let received = (batch.values().len() as u64).saturating_mul(4);
371 record_payload(&*caller, ticket, Side::Response, || {
372 Payload::meta(serde_json::json!({
373 "count": batch.count(),
374 "dimensions": batch.dimensions(),
375 "identity": batch.identity(),
376 "model": model,
377 "inputTokens": batch.input_tokens(),
378 }))
379 .with_size(received)
380 });
381 record_usage(
382 &*caller,
383 ticket,
384 ModelUsage {
385 input_tokens: batch.input_tokens(),
386 output_tokens: None,
387 },
388 );
389 fuel::settle(&mut *caller, fuel::IO, received)?;
390 Ok(batch)
391 }
392 Err(error) => {
393 let reported = error
395 .settlements()
396 .iter()
397 .fold(0u64, |sum, part| sum.saturating_add(part.reported));
398 if reported > 0 {
399 record_usage(
400 &*caller,
401 ticket,
402 ModelUsage {
403 input_tokens: Some(reported),
404 output_tokens: None,
405 },
406 );
407 }
408 Err(quota_throw(caller, ticket, model, error))
409 }
410 };
411 let result = match result {
412 Err(error) if crate::runtime::host::ends_the_run(&error) => return Err(error),
413 result => result.map_err(|error| NativeError::capture(&error)),
414 };
415 Ok(EmbeddingResponse {
416 result,
417 used,
418 held,
419 requests,
420 settlement: Some(settlement),
421 })
422}
423
424fn gate(
427 caller: &mut wasmtime::Caller<'_, StoreData>,
428 model: &str,
429 input_count: usize,
430) -> wasmtime::Result<Option<CallTicket>> {
431 check_security_call(
432 caller,
433 CAPABILITY,
434 serde_json::json!({ "model": model, "input_count": input_count }),
435 )
436}
437
438fn read_texts(
441 caller: &mut wasmtime::Caller<'_, StoreData>,
442 model: &str,
443 elements: &[Val],
444 limits: &EmbeddingLimits,
445) -> wasmtime::Result<Vec<String>> {
446 let count = elements.len() as u64;
447 if count > limits.max_texts_per_call {
448 return Err(throw(EmbeddingError::BoundsExceeded {
449 alias: model.to_string(),
450 kind: EmbeddingBoundKind::TextCount,
451 actual: count,
452 limit: limits.max_texts_per_call,
453 }));
454 }
455 if elements.is_empty() {
456 return Err(range_error(format!(
457 "embedding.embed(\"{model}\"): at least one text is required — pass a non-empty array"
458 )));
459 }
460 let mut texts = Vec::with_capacity(elements.len());
461 let mut total = 0u64;
462 for element in elements {
463 let text = read_string_arg(caller, element, "embedding.embed (texts)")?;
464 total = total.saturating_add(text.len() as u64);
465 if total > limits.max_bytes_per_call {
466 return Err(throw(EmbeddingError::BoundsExceeded {
467 alias: model.to_string(),
468 kind: EmbeddingBoundKind::TotalBytes,
469 actual: total,
470 limit: limits.max_bytes_per_call,
471 }));
472 }
473 texts.push(text);
474 }
475 Ok(texts)
476}
477
478fn parse_purpose(model: &str, purpose: &str) -> wasmtime::Result<Purpose> {
479 match purpose {
480 "query" => Ok(Purpose::Query),
481 "document" => Ok(Purpose::Document),
482 _ => Err(range_error(format!(
483 "embedding.embed(\"{model}\"): purpose must be \"query\" or \"document\""
484 ))),
485 }
486}
487
488async fn check_inputs(
491 caller: &mut wasmtime::Caller<'_, StoreData>,
492 provider: &dyn EmbeddingProvider,
493 model: &str,
494 texts: &[String],
495) -> wasmtime::Result<()> {
496 let Some(limit) = provider.max_input_bytes(model) else {
497 let available = available_aliases(caller, provider).await?;
498 return Err(throw(EmbeddingError::UnknownModel {
499 alias: model.to_string(),
500 available,
501 }));
502 };
503 let too_long = texts.iter().position(|text| text.len() as u64 > limit);
507 if let Some(index) = too_long {
508 return Err(throw(EmbeddingError::InputTooLong {
509 alias: model.to_string(),
510 index: Some(index),
511 limit: Some(limit),
512 settlements: Vec::new(),
513 }));
514 }
515 Ok(())
516}
517
518async fn available_aliases(
522 caller: &mut wasmtime::Caller<'_, StoreData>,
523 provider: &dyn EmbeddingProvider,
524) -> wasmtime::Result<Vec<String>> {
525 let candidates = provider.models().await.map_err(throw)?;
526 let mut names = Vec::new();
527 for candidate in candidates {
528 if may_embed(caller, &candidate.name)? {
529 names.push(candidate.name);
530 }
531 }
532 Ok(names)
533}
534
535async fn models(caller: &mut wasmtime::Caller<'_, StoreData>) -> wasmtime::Result<Val> {
542 preflight_models(caller, CAPABILITY, "input_count")?;
543 let candidates = effects::execute(caller, "embedding.models", (), |caller, _, ()| {
544 Box::pin(async move { provider(caller, "")?.models().await.map_err(throw) })
545 })
546 .await?;
547 let mut built = Vec::with_capacity(candidates.len());
548 for candidate in candidates {
549 if may_embed(caller, &candidate.name)? {
550 built.push(build_model(caller, candidate)?);
551 }
552 }
553 abi::new_array(caller, &built)
554}
555
556fn may_embed(caller: &mut wasmtime::Caller<'_, StoreData>, model: &str) -> wasmtime::Result<bool> {
560 let keeps = filters_candidate(gate(caller, model, 0).map(|_| ()))?;
561 if !keeps {
562 mark_filtered(&*caller);
563 }
564 Ok(keeps)
565}
566
567fn provider(
570 caller: &wasmtime::Caller<'_, StoreData>,
571 model: &str,
572) -> wasmtime::Result<Arc<dyn EmbeddingProvider>> {
573 caller.data().embedding_provider.clone().ok_or_else(|| {
574 throw(EmbeddingError::NotConfigured {
575 alias: model.to_string(),
576 })
577 })
578}
579
580fn execution_budget(caller: &mut wasmtime::Caller<'_, StoreData>) -> Arc<EmbeddingTokenBudget> {
583 caller
584 .data_mut()
585 .embedding_budget
586 .get_or_insert_with(|| Arc::new(EmbeddingTokenBudget::unmetered()))
587 .clone()
588}
589
590fn quota_throw(
592 caller: &wasmtime::Caller<'_, StoreData>,
593 ticket: Option<CallTicket>,
594 model: &str,
595 error: EmbeddingError,
596) -> wasmtime::Error {
597 if !error.is_budget_exceeded() {
598 return throw(error);
599 }
600 if let Err(denial) = audit_quota_denial(
601 caller,
602 ticket,
603 CAPABILITY,
604 model,
605 "embedding-token budget exceeded",
606 ) {
607 return denial;
608 }
609 throw(error)
610}
611
612fn throw(error: EmbeddingError) -> wasmtime::Error {
621 let message = error.to_string();
622 match error {
623 EmbeddingError::BudgetExceeded { .. } => quota_exceeded_error(message),
624 EmbeddingError::BoundsExceeded { .. } | EmbeddingError::InputTooLong { .. } => {
625 range_error(message)
626 }
627 EmbeddingError::Malformed { .. } => type_error(message),
628 EmbeddingError::Internal { .. } => fatal_host_error(message),
629 EmbeddingError::NotConfigured { .. }
630 | EmbeddingError::UnknownModel { .. }
631 | EmbeddingError::Unauthorized { .. }
632 | EmbeddingError::Provider { .. } => wasmtime::Error::msg(message),
633 }
634}
635
636fn embeddings_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
637 let intr = build_intrinsic_types(engine)?;
638 backing_struct(
639 engine,
640 &intr,
641 vec![
642 raw_bytes_field(&intr), f64_field(), f64_field(), string_field(&intr), string_field(&intr), nullable_object_field(&intr), ],
649 )
650}
651
652fn model_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
653 let intr = build_intrinsic_types(engine)?;
654 backing_struct(
655 engine,
656 &intr,
657 vec![
658 string_field(&intr), nullable_object_field(&intr), f64_field(), nullable_object_field(&intr), f64_field(), string_field(&intr), ],
665 )
666}
667
668fn build_embeddings(
677 caller: &mut wasmtime::Caller<'_, StoreData>,
678 batch: EmbeddingBatch,
679) -> wasmtime::Result<Val> {
680 let byte_count = batch
681 .values()
682 .len()
683 .checked_mul(4)
684 .ok_or_else(|| fatal_host_error("embedding.embed: result size overflows"))?;
685 fuel::charge(&mut *caller, fuel::COPY, byte_count as u64)?;
686 let mut bytes = Vec::new();
687 bytes
688 .try_reserve_exact(byte_count)
689 .map_err(|error| fatal_host_error(format!("embedding.embed: {error}")))?;
690 for value in batch.values() {
691 bytes.extend_from_slice(&value.to_le_bytes());
692 }
693 let count = batch.count() as f64;
694 let dimensions = batch.dimensions() as f64;
695 let identity = batch.identity().to_string();
696 let model = batch.model().to_string();
697 let input_tokens = batch.input_tokens().map(|n| n as f64);
698 drop(batch);
699
700 let array_ty = build_intrinsic_types(caller.engine())?.raw_uint8_array;
702 let vectors = write_uint8_array_precharged(&mut *caller, array_ty, &bytes)?;
703 drop(bytes);
704
705 let identity = string_val(caller, &identity)?;
706 let model = string_val(caller, &model)?;
707 let input_tokens = optional_number(caller, input_tokens)?;
708
709 let ty = embeddings_backing_struct(caller.engine())?;
710 abi::new_backing(
711 caller,
712 ty,
713 &[
714 Val::AnyRef(Some(vectors.to_anyref())),
715 Val::F64(count.to_bits()),
716 Val::F64(dimensions.to_bits()),
717 identity,
718 model,
719 input_tokens,
720 ],
721 )
722}
723
724fn build_model(
725 caller: &mut wasmtime::Caller<'_, StoreData>,
726 model: EmbeddingModel,
727) -> wasmtime::Result<Val> {
728 let name = string_val(caller, &model.name)?;
729 let description = match model.description.as_deref().and_then(sanitize_description) {
730 Some(text) => string_val(caller, &text)?,
731 None => crate::runtime::prelude::undefined::value(caller)?,
732 };
733 let max_input_tokens = optional_number(caller, model.max_input_tokens.map(|n| n as f64))?;
734 let identity = string_val(caller, &model.identity)?;
735 let ty = model_backing_struct(caller.engine())?;
736 abi::new_backing(
737 caller,
738 ty,
739 &[
740 name,
741 description,
742 Val::F64((model.dimensions as f64).to_bits()),
743 max_input_tokens,
744 Val::F64((model.max_input_bytes as f64).to_bits()),
745 identity,
746 ],
747 )
748}
749
750fn string_val(caller: &mut wasmtime::Caller<'_, StoreData>, text: &str) -> wasmtime::Result<Val> {
751 Ok(Val::AnyRef(Some(
752 write_submilli_string_struct(caller, text)?.to_anyref(),
753 )))
754}
755
756fn read_row(
759 caller: &mut wasmtime::Caller<'_, StoreData>,
760 receiver: &Val,
761 index: &Val,
762 ctx: &str,
763) -> wasmtime::Result<Vec<u8>> {
764 let Val::F64(bits) = index else {
765 return Err(fatal_host_error(format!("{ctx}: index is not a number")));
766 };
767 let index = f64::from_bits(*bits);
768
769 let st = abi::backing_receiver(caller, receiver)?;
770 let Val::AnyRef(Some(vectors)) = st.field(&mut *caller, E_VECTORS)? else {
771 return Err(fatal_host_error(format!(
772 "{ctx}: result vectors are missing"
773 )));
774 };
775 let vectors = vectors
776 .as_array(&mut *caller)?
777 .ok_or_else(|| fatal_host_error(format!("{ctx}: result vectors are not an array")))?;
778 let (Val::F64(count), Val::F64(dimensions)) = (
779 st.field(&mut *caller, E_COUNT)?,
780 st.field(&mut *caller, E_DIMENSIONS)?,
781 ) else {
782 return Err(fatal_host_error(format!("{ctx}: result shape is missing")));
783 };
784 let (count, dimensions) = (f64::from_bits(count), f64::from_bits(dimensions));
785 let in_range = index.is_finite() && index.fract() == 0.0 && index >= 0.0 && index < count;
786 if !in_range {
787 return Err(range_error(format!(
788 "{ctx}: index {index} is out of range for {count} vectors — use an integer from 0 to \
789 count - 1"
790 )));
791 }
792 let row_bytes = (dimensions as usize)
793 .checked_mul(4)
794 .ok_or_else(|| fatal_host_error(format!("{ctx}: row size overflows")))?;
795 let offset = (index as usize)
796 .checked_mul(row_bytes)
797 .and_then(|offset| u32::try_from(offset).ok())
798 .ok_or_else(|| fatal_host_error(format!("{ctx}: row offset overflows")))?;
799 fuel::charge(&mut *caller, fuel::COPY, row_bytes as u64)?;
801 let mut row = Vec::new();
802 row.try_reserve_exact(row_bytes)
803 .map_err(|error| fatal_host_error(format!("{ctx}: {error}")))?;
804 row.resize(row_bytes, 0);
805 vectors
806 .read_i8(&mut *caller, offset, &mut row)
807 .map_err(|error| fatal_host_error(format!("{ctx}: {error}")))?;
808 Ok(row)
809}
810
811fn build_number_array(
813 caller: &mut wasmtime::Caller<'_, StoreData>,
814 row: &[u8],
815) -> wasmtime::Result<Val> {
816 fuel::charge(&mut *caller, fuel::ELEM, (row.len() / 4) as u64)?;
818 let mut boxed = Vec::with_capacity(row.len() / 4);
819 for chunk in row.as_chunks::<4>().0 {
820 let value = f32::from_le_bytes(*chunk);
821 boxed.push(Val::AnyRef(Some(
822 write_boxed_number_struct(caller, f64::from(value))?.to_anyref(),
823 )));
824 }
825 Ok(Val::AnyRef(Some(
826 write_submilli_array_struct_precharged(caller, &boxed)?.to_anyref(),
827 )))
828}
829
830#[cfg(test)]
831mod tests {
832 use std::pin::Pin;
833 use std::sync::atomic::{AtomicUsize, Ordering};
834 use std::sync::{Arc, Mutex};
835
836 use crate::runtime::{
837 CheckOutcome, EmbeddingBatch, EmbeddingError, EmbeddingFailureReason, EmbeddingLimits,
838 EmbeddingModel, EmbeddingProvider, EmbeddingTokenBudget, Purpose, RuntimeConfig,
839 SecurityCheck, SharedTokenBudget, StoreData, SubBatchSettlement, Vfs, dispatch_main_async,
840 install_runtime_async,
841 };
842
843 type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
844
845 #[derive(Clone, Copy)]
847 enum Outcome {
848 Reported,
850 TransportFailure,
852 Internal,
854 }
855
856 struct MockProvider {
859 dimensions: usize,
860 max_input_bytes: u64,
861 outcome: Outcome,
862 embed_calls: AtomicUsize,
863 }
864
865 impl MockProvider {
866 fn new(dimensions: usize, max_input_bytes: u64, outcome: Outcome) -> Arc<Self> {
867 Arc::new(Self {
868 dimensions,
869 max_input_bytes,
870 outcome,
871 embed_calls: AtomicUsize::new(0),
872 })
873 }
874
875 fn calls(&self) -> usize {
876 self.embed_calls.load(Ordering::Relaxed)
877 }
878 }
879
880 impl EmbeddingProvider for MockProvider {
881 fn embed<'a>(
882 &'a self,
883 alias: &'a str,
884 texts: &'a [String],
885 _purpose: Purpose,
886 budget: &'a EmbeddingTokenBudget,
887 ) -> BoxFuture<'a, Result<EmbeddingBatch, EmbeddingError>> {
888 self.embed_calls.fetch_add(1, Ordering::Relaxed);
889 Box::pin(async move {
890 let estimate = self.estimate_tokens(alias, texts);
891 budget.mark_sent(alias, estimate)?;
892 if matches!(self.outcome, Outcome::Internal) {
893 return Err(EmbeddingError::Internal {
894 alias: alias.to_string(),
895 settlements: vec![SubBatchSettlement {
896 estimate,
897 reported: 0,
898 indeterminate: estimate,
899 }],
900 });
901 }
902 if matches!(self.outcome, Outcome::TransportFailure) {
903 return Err(EmbeddingError::Provider {
904 alias: alias.to_string(),
905 reason: EmbeddingFailureReason::Transport,
906 settlements: vec![SubBatchSettlement {
907 estimate,
908 reported: 0,
909 indeterminate: estimate,
910 }],
911 });
912 }
913 let values = vec![0.5f32; texts.len() * self.dimensions];
914 EmbeddingBatch::new(values, texts.len(), self.dimensions, "emb1:mock", alias)
915 .map(|batch| {
916 batch.with_settlements(vec![SubBatchSettlement {
917 estimate,
918 reported: estimate,
919 indeterminate: 0,
920 }])
921 })
922 .map_err(|_| EmbeddingError::Malformed {
923 alias: alias.to_string(),
924 reason: crate::runtime::EmbeddingMalformedReason::InvalidBody,
925 settlements: Vec::new(),
926 })
927 })
928 }
929
930 fn models<'a>(&'a self) -> BoxFuture<'a, Result<Vec<EmbeddingModel>, EmbeddingError>> {
931 let model = EmbeddingModel {
932 name: "mock".to_string(),
933 description: None,
934 dimensions: self.dimensions as u64,
935 max_input_tokens: None,
936 max_input_bytes: self.max_input_bytes,
937 identity: "emb1:mock".to_string(),
938 };
939 Box::pin(async move { Ok(vec![model]) })
940 }
941
942 fn max_input_bytes(&self, alias: &str) -> Option<u64> {
943 (alias == "mock").then_some(self.max_input_bytes)
944 }
945 }
946
947 struct RecordingPolicy(Arc<Mutex<Vec<serde_json::Value>>>);
948
949 impl SecurityCheck for RecordingPolicy {
950 fn check(
951 &self,
952 _caller: &str,
953 _capability: &str,
954 context: &serde_json::Value,
955 ) -> CheckOutcome {
956 if let Ok(mut contexts) = self.0.lock() {
957 contexts.push(context.clone());
958 }
959 CheckOutcome::Allow { rule: None }
960 }
961 }
962
963 struct Run {
966 result: wasmtime::Result<String>,
967 host_attached_bytes: u64,
968 observed_bytes: u64,
970 }
971
972 async fn run(
973 source: &str,
974 provider: Option<Arc<MockProvider>>,
975 budget: Option<Arc<EmbeddingTokenBudget>>,
976 policy: Option<Arc<dyn SecurityCheck>>,
977 ) -> Run {
978 let compiled = crate::compile_script(source, "test.ts", crate::FileId(0), &[], &[])
979 .expect("compile clean");
980 let cfg = RuntimeConfig::default();
981 let engine = cfg.engine().expect("engine");
982 let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
983 data.install_type_info(compiled.type_info.clone());
984 data.embedding_provider = provider.map(|p| p as Arc<dyn EmbeddingProvider>);
985 data.embedding_budget = budget;
986 if let Some(policy) = policy {
987 data.security_check = policy;
988 }
989 let mut store = cfg.store_async(&engine, data).expect("store");
990 crate::runtime::install_tenant_limits(&mut store);
991 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
992 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
993 install_runtime_async(&mut linker, &mut store)
994 .await
995 .expect("install");
996 let inst = linker
997 .instantiate_async(&mut store, &module)
998 .await
999 .expect("instantiate");
1000 let result = dispatch_main_async(&mut store, &inst)
1001 .await
1002 .map(Option::unwrap_or_default);
1003 let host_attached_bytes = store.data().tenant_limits.host_attached_bytes();
1004 let observed_bytes = store.data().tenant_limits.observed_bytes();
1005 Run {
1006 result,
1007 host_attached_bytes,
1008 observed_bytes,
1009 }
1010 }
1011
1012 fn budget(limits: EmbeddingLimits) -> Arc<EmbeddingTokenBudget> {
1013 Arc::new(EmbeddingTokenBudget::new(
1014 limits,
1015 SharedTokenBudget::new(u64::MAX),
1016 ))
1017 }
1018
1019 const ROUND_TRIP: &str = r#"import embedding from "submilli:embedding";
1020 function main(): void {
1021 const r = embedding.embed("mock", ["a", "b", "c"], "document");
1022 assert(r.count === 3, "three vectors");
1023 }"#;
1024
1025 #[tokio::test]
1028 async fn embeddings_and_models_stringify_their_properties() {
1029 let provider = MockProvider::new(4, 10, Outcome::Reported);
1030 let run = run(
1031 r#"import embedding from "submilli:embedding";
1032 function main(): string {
1033 const r = embedding.embed("mock", ["a"], "document");
1034 return JSON.stringify(r)! + "|" + JSON.stringify(embedding.models())!;
1035 }"#,
1036 Some(provider),
1037 None,
1038 None,
1039 )
1040 .await;
1041 let out = run.result.expect("program completes");
1042 let (embeddings, models) = out.split_once('|').expect("two values");
1043 let embeddings: serde_json::Value = serde_json::from_str(embeddings).expect("JSON");
1044 assert_eq!(embeddings["count"], 1, "{embeddings}");
1045 assert_eq!(embeddings["dimensions"], 4, "{embeddings}");
1046 assert_eq!(embeddings["model"], "mock", "{embeddings}");
1047 assert!(embeddings.get("vectors").is_none(), "{embeddings}");
1048 assert_eq!(
1049 models,
1050 r#"[{"name":"mock","dimensions":4,"maxInputBytes":10,"identity":"emb1:mock"}]"#
1051 );
1052 }
1053
1054 #[tokio::test]
1057 async fn ae1_over_length_input_is_refused_before_the_provider_and_the_budget() {
1058 let provider = MockProvider::new(4, 10, Outcome::Reported);
1059 let budget = budget(EmbeddingLimits::default());
1060 let run = run(
1061 r#"import embedding from "submilli:embedding";
1062 function main(): void {
1063 let message = "";
1064 try {
1065 embedding.embed("mock", ["ok", "ok", "this one is too long"], "document");
1066 } catch (e: RangeError) {
1067 message = e.message;
1068 }
1069 assert(message.indexOf("text 2") >= 0, "names index 2: " + message);
1070 }"#,
1071 Some(Arc::clone(&provider)),
1072 Some(Arc::clone(&budget)),
1073 None,
1074 )
1075 .await;
1076 run.result.expect("program completes");
1077 assert_eq!(provider.calls(), 0, "the provider is never called");
1078 assert_eq!(budget.used(), 0, "nothing is charged");
1079 assert_eq!(budget.held(), 0, "nothing is held");
1080 assert_eq!(budget.requests(), 0, "nothing counts as sent");
1081 }
1082
1083 #[tokio::test]
1086 async fn ae6_exhausted_budget_refuses_without_calling_the_provider() {
1087 let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1088 let budget = budget(EmbeddingLimits {
1089 per_execution_tokens: 1,
1090 ..EmbeddingLimits::default()
1091 });
1092 let run = run(
1093 r#"import embedding from "submilli:embedding";
1094 function main(): void {
1095 let quota = false;
1096 try {
1097 embedding.embed("mock", ["more than one token of text"], "document");
1098 } catch (e: QuotaExceededError) {
1099 quota = true;
1100 }
1101 assert(quota, "the call is a QuotaExceededError");
1102 }"#,
1103 Some(Arc::clone(&provider)),
1104 Some(Arc::clone(&budget)),
1105 None,
1106 )
1107 .await;
1108 run.result.expect("program completes");
1109 assert_eq!(provider.calls(), 0, "the provider is never called");
1110 assert_eq!(budget.used(), 0, "the refusal charged nothing");
1111 assert_eq!(budget.requests(), 0);
1112 }
1113
1114 #[tokio::test]
1117 async fn success_settles_reported_usage() {
1118 let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1119 let budget = budget(EmbeddingLimits::default());
1120 let run = run(ROUND_TRIP, Some(provider), Some(Arc::clone(&budget)), None).await;
1121 run.result.expect("program completes");
1122 assert_eq!(
1123 budget.used(),
1124 3,
1125 "three one-byte texts estimate one token each"
1126 );
1127 assert_eq!(budget.held(), 0, "reported usage leaves nothing held");
1128 assert_eq!(budget.requests(), 1);
1129 }
1130
1131 #[tokio::test]
1134 async fn failure_after_send_settles_the_estimate_as_held() {
1135 let provider = MockProvider::new(4, 1_000, Outcome::TransportFailure);
1136 let budget = budget(EmbeddingLimits::default());
1137 let run = run(
1138 r#"import embedding from "submilli:embedding";
1139 function main(): void {
1140 let message = "";
1141 try {
1142 embedding.embed("mock", ["secret text"], "document");
1143 } catch (e: Error) {
1144 message = e.message;
1145 }
1146 assert(message.indexOf("transport") >= 0, message);
1147 assert(message.indexOf("secret text") < 0, "no input text in the error");
1148 }"#,
1149 Some(provider),
1150 Some(Arc::clone(&budget)),
1151 None,
1152 )
1153 .await;
1154 run.result.expect("program completes");
1155 assert_eq!(budget.held(), 4, "ceil(11 / 3) tokens stay held");
1156 assert_eq!(budget.used(), 4, "and count against the run");
1157 }
1158
1159 #[tokio::test]
1163 async fn ae8_results_are_held_at_four_bytes_per_number() {
1164 const COUNT: u64 = 128;
1165 const DIMENSIONS: u64 = 3072;
1166 let source = |texts: u32| {
1167 format!(
1168 r#"import embedding from "submilli:embedding";
1169 function main(): void {{
1170 const texts: string[] = [];
1171 for (let i = 0; i < {texts}; i = i + 1) {{
1172 texts.push("t");
1173 }}
1174 const r = embedding.embed("mock", texts, "document");
1175 assert(r.count === {texts} && r.dimensions === 3072, "shape");
1176 assert(r.vector(0).length === 3072, "reading vector 0 returns 3,072 numbers");
1177 assert(r.bytes(0).length === 12288, "exporting it returns 12,288 bytes");
1178 if ({texts} > 5) {{
1179 assert(r.vector(5).length === 3072, "reading vector 5 returns 3,072 numbers");
1180 assert(r.bytes(5).length === 12288, "exporting it returns 12,288 bytes");
1181 }}
1182 let range = false;
1183 try {{
1184 r.vector({texts});
1185 }} catch (e: RangeError) {{
1186 range = true;
1187 }}
1188 assert(range, "reading vector {texts} is a RangeError");
1189 }}"#
1190 )
1191 };
1192 let provider = |_| MockProvider::new(DIMENSIONS as usize, 1_000, Outcome::Reported);
1193 let one = run(&source(1), Some(provider(())), None, None).await;
1194 one.result.expect("one-vector program completes");
1195 let many = run(&source(COUNT as u32), Some(provider(())), None, None).await;
1196 many.result.expect("program completes");
1197
1198 let expected = (COUNT - 1) * DIMENSIONS * 4;
1202 let grown = many.observed_bytes.saturating_sub(one.observed_bytes);
1203 assert!(
1204 grown >= expected - expected / 10 && grown <= expected + expected / 10,
1205 "the result costs about count x dimensions x 4 bytes of GC heap: grew {grown}, \
1206 expected about {expected}"
1207 );
1208 assert_eq!(
1209 many.host_attached_bytes, 0,
1210 "the vectors are not host-attached bytes"
1211 );
1212 }
1213
1214 #[tokio::test]
1219 async fn discarded_results_are_collected_under_the_default_cap() {
1220 let provider = MockProvider::new(3072, 1_000, Outcome::Reported);
1221 let run = run(
1222 r#"import embedding from "submilli:embedding";
1223 function main(): void {
1224 const texts: string[] = [];
1225 for (let i = 0; i < 128; i = i + 1) {
1226 texts.push("t");
1227 }
1228 let total = 0;
1229 for (let round = 0; round < 100; round = round + 1) {
1230 const r = embedding.embed("mock", texts, "document");
1231 total = total + r.count;
1232 }
1233 assert(total === 12800, "every round returned its vectors");
1234 }"#,
1235 Some(provider),
1236 Some(budget(EmbeddingLimits::default())),
1237 None,
1238 )
1239 .await;
1240 run.result
1241 .expect("program completes without memory exhaustion");
1242 }
1243
1244 #[tokio::test]
1247 async fn the_hidden_vectors_are_not_reachable_from_the_guest() {
1248 for member in ["vectors", "handle", "data", "buffer"] {
1249 let source = format!(
1250 r#"import embedding from "submilli:embedding";
1251 function main(): void {{
1252 const r = embedding.embed("mock", ["a"], "document");
1253 const hidden = r.{member};
1254 }}"#
1255 );
1256 assert!(
1257 crate::compile_script(&source, "test.ts", crate::FileId(0), &[], &[]).is_err(),
1258 "`Embeddings.{member}` must not type-check"
1259 );
1260 }
1261 let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1262 let cast = run(
1263 r#"import embedding from "submilli:embedding";
1264 function main(): void {
1265 const r = embedding.embed("mock", ["a"], "document");
1266 const u = r as unknown as Uint8Array;
1267 u[0] = 255;
1268 }"#,
1269 Some(provider),
1270 None,
1271 None,
1272 )
1273 .await;
1274 assert!(
1275 cast.result.is_err(),
1276 "casting the result to a Uint8Array must fail, not expose the vectors"
1277 );
1278
1279 for call in [
1284 "crypto.sha256(x)",
1285 "crypto.hmacSha256(crypto.randomBytes(4), x)",
1286 ] {
1287 let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1288 let source = format!(
1289 r#"import embedding from "submilli:embedding";
1290 import crypto from "submilli:crypto";
1291 function main(): void {{
1292 const r = embedding.embed("mock", ["a"], "document");
1293 const x = r as unknown as (string | Uint8Array);
1294 crypto.sha256(crypto.randomBytes(1));
1295 {call};
1296 }}"#
1297 );
1298 let outcome = run(&source, Some(provider), None, None).await;
1299 assert!(
1300 outcome.result.is_err(),
1301 "`{call}` on the result must fail, not hash the vectors"
1302 );
1303 }
1304 }
1305
1306 #[tokio::test]
1310 async fn a_host_read_of_the_result_as_a_uint8array_is_refused() {
1311 let compiled = crate::compile_script(
1312 "function main(): void {}",
1313 "test.ts",
1314 crate::FileId(0),
1315 &[],
1316 &[],
1317 )
1318 .expect("compile clean");
1319 let cfg = RuntimeConfig::default();
1320 let engine = cfg.engine().expect("engine");
1321 let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1322 data.install_type_info(compiled.type_info.clone());
1323 let mut store = cfg.store_async(&engine, data).expect("store");
1324 crate::runtime::install_tenant_limits(&mut store);
1325 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1326 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1327 install_runtime_async(&mut linker, &mut store)
1328 .await
1329 .expect("install");
1330 linker
1331 .instantiate_async(&mut store, &module)
1332 .await
1333 .expect("instantiate");
1334
1335 let probe = wasmtime::Func::new(
1336 &mut store,
1337 wasmtime::FuncType::new(&engine, [], []),
1338 |mut caller, _, _| {
1339 let batch = EmbeddingBatch::new(vec![1.0, 2.0], 1, 2, "id", "mock")
1340 .map_err(wasmtime::Error::new)?;
1341 let sealed = super::build_embeddings(&mut caller, batch)?;
1342 let refused =
1343 crate::runtime::host::read_uint8_array_arg(&mut caller, &sealed, "probe");
1344 match refused {
1345 Err(error) if error.to_string().contains("expects a Uint8Array") => Ok(()),
1346 other => Err(wasmtime::Error::msg(format!(
1347 "the sealed result was not refused: {other:?}"
1348 ))),
1349 }
1350 },
1351 );
1352 probe
1353 .call_async(&mut store, &[], &mut [])
1354 .await
1355 .expect("the host refuses to read the sealed result as bytes");
1356 }
1357
1358 #[tokio::test]
1361 async fn the_result_charge_counts_against_the_run_memory_cap() {
1362 let provider = MockProvider::new(3072, 1_000, Outcome::Reported);
1363 let compiled = crate::compile_script(
1364 r#"import embedding from "submilli:embedding";
1365 function main(): void {
1366 const texts: string[] = [];
1367 for (let i = 0; i < 128; i = i + 1) {
1368 texts.push("t");
1369 }
1370 embedding.embed("mock", texts, "document");
1371 }"#,
1372 "test.ts",
1373 crate::FileId(0),
1374 &[],
1375 &[],
1376 )
1377 .expect("compile clean");
1378 let cfg = RuntimeConfig::default();
1379 let engine = cfg.engine().expect("engine");
1380 let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1381 data.install_type_info(compiled.type_info.clone());
1382 data.embedding_provider = Some(provider);
1383 let budget = budget(EmbeddingLimits::default());
1384 data.embedding_budget = Some(Arc::clone(&budget));
1385 let mut store = cfg.store_async(&engine, data).expect("store");
1386 crate::runtime::install_tenant_limits(&mut store);
1387 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1388 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1389 install_runtime_async(&mut linker, &mut store)
1390 .await
1391 .expect("install");
1392 let inst = linker
1393 .instantiate_async(&mut store, &module)
1394 .await
1395 .expect("instantiate");
1396 let cap = store.data().tenant_limits.observed_bytes() + 100_000;
1399 store.data_mut().tenant_limits.max_total_bytes = cap;
1400 let result = dispatch_main_async(&mut store, &inst).await;
1401 let error = result.expect_err("the cap refuses the result");
1402 assert!(
1403 crate::runtime::is_memory_exhausted(&error),
1404 "a memory refusal ends the run: {error:?}"
1405 );
1406 assert_eq!(budget.used(), 128, "the reported usage is settled");
1409 assert_eq!(budget.held(), 0, "nothing stays held");
1410 }
1411
1412 #[tokio::test]
1415 async fn an_internal_provider_failure_ends_the_run_and_settles_the_budget() {
1416 let provider = MockProvider::new(4, 1_000, Outcome::Internal);
1417 let budget = budget(EmbeddingLimits::default());
1418 let run = run(
1419 r#"import embedding from "submilli:embedding";
1420 function main(): void {
1421 try {
1422 embedding.embed("mock", ["secret text"], "document");
1423 } catch (e: Error) {
1424 assert(false, "an internal failure must not be catchable");
1425 }
1426 }"#,
1427 Some(provider),
1428 Some(Arc::clone(&budget)),
1429 None,
1430 )
1431 .await;
1432 let error = run.result.expect_err("the run ends");
1433 assert!(
1434 crate::runtime::host::ends_the_run(&error),
1435 "an internal failure is fatal: {error:?}"
1436 );
1437 assert_eq!(budget.held(), 4, "ceil(11 / 3) tokens stay held");
1438 assert_eq!(budget.used(), 4);
1439 }
1440
1441 #[tokio::test]
1444 async fn the_filter_context_carries_the_numbers_and_never_the_text() {
1445 const SECRET: &str = "the patient's diagnosis is confidential";
1446 let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1447 let contexts = Arc::new(Mutex::new(Vec::new()));
1448 let policy: Arc<dyn SecurityCheck> = Arc::new(RecordingPolicy(Arc::clone(&contexts)));
1449 let run = run(
1450 &format!(
1451 r#"import embedding from "submilli:embedding";
1452 function main(): void {{
1453 embedding.embed("mock", ["{SECRET}", "b"], "query");
1454 embedding.models();
1455 }}"#
1456 ),
1457 Some(provider),
1458 None,
1459 Some(policy),
1460 )
1461 .await;
1462 run.result.expect("program completes");
1463
1464 let contexts = contexts.lock().expect("contexts");
1465 assert_eq!(contexts.len(), 2, "one check per embed, one per candidate");
1466 for context in contexts.iter() {
1467 let mut keys: Vec<&str> = context
1468 .as_object()
1469 .expect("an object context")
1470 .keys()
1471 .map(String::as_str)
1472 .collect();
1473 keys.sort_unstable();
1474 assert_eq!(keys, ["input_count", "model"]);
1475 assert!(!context.to_string().contains("patient"), "{context}");
1476 }
1477 assert_eq!(contexts[0]["input_count"], 2, "embed: the text count");
1478 assert_eq!(contexts[1]["input_count"], 0, "models: zero");
1479 }
1480
1481 #[tokio::test]
1484 async fn an_unknown_alias_names_the_available_ones_and_charges_nothing() {
1485 let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1486 let budget = budget(EmbeddingLimits::default());
1487 let run = run(
1488 r#"import embedding from "submilli:embedding";
1489 function main(): void {
1490 let message = "";
1491 try {
1492 embedding.embed("ghost", ["a"], "document");
1493 } catch (e: Error) {
1494 message = e.message;
1495 }
1496 assert(message.indexOf("mock") >= 0, message);
1497 }"#,
1498 Some(Arc::clone(&provider)),
1499 Some(Arc::clone(&budget)),
1500 None,
1501 )
1502 .await;
1503 run.result.expect("program completes");
1504 assert_eq!(provider.calls(), 0);
1505 assert_eq!(budget.used(), 0);
1506 }
1507}