1use crate::ProviderAuth;
2use crate::client::{HttpRunnerRequest, post_json, post_json_stream};
3use crate::config::ProviderConfig;
4use crate::model_params::attach_bridge_model_params_to_body;
5use crate::redact::redact_text;
6use crate::stream::HttpStreamDecoder;
7use sim_codec_chat::{
8 AnthropicRequestOptions, LemonadeRequestOptions, LmStudioRequestOptions, OllamaRequestOptions,
9 OpenAiRequestOptions, decode_anthropic_response, decode_anthropic_stream,
10 decode_lemonade_response, decode_lemonade_stream, decode_lm_studio_response,
11 decode_lm_studio_stream, decode_ollama_response, decode_ollama_stream, decode_openai_response,
12 encode_anthropic_request, encode_lemonade_request, encode_lm_studio_request,
13 encode_ollama_request, encode_openai_request, model_error_expr,
14};
15use sim_kernel::{
16 CapabilityName, Cx, Datum, DatumStore, Effect, Error, Expr, Ref, Result, Symbol, core_any_ref,
17 effect, value_from_ref,
18};
19use sim_lib_agent_runner_core::{
20 ModelCard, ModelEvent, ModelEventSink, ModelRequest, ModelResponse, ModelRunner,
21 OUTPUT_GRAMMAR_DIALECT_EXTRA, OUTPUT_GRAMMAR_EXTRA, OUTPUT_GRAMMAR_REQUIRED_EXTRA,
22 RETURN_CODEC_EXTRA, RETURN_SHAPE_EXTRA, grammar_dialect_symbol,
23};
24use sim_shape::GrammarDialect;
25use std::time::Duration;
26
27#[derive(Clone, Debug)]
29pub struct HttpRunner {
30 runner: Symbol,
31 model: String,
32 provider: Symbol,
33 locality: Symbol,
34 runner_label: &'static str,
35 request_path: &'static str,
36 endpoint: String,
37 api_key_env: Option<String>,
38 auth: ProviderAuth,
39 codec: Symbol,
40 timeout: Duration,
41 stream: bool,
42 tools: bool,
43 max_response_bytes: usize,
44 grammar_dialects: Vec<GrammarDialect>,
45}
46
47impl HttpRunner {
48 pub fn new_provider(config: ProviderConfig) -> Self {
51 let auth = config.profile.auth.clone();
52 Self {
53 runner: config.runner,
54 model: config.model,
55 provider: config.profile.provider,
56 locality: config.locality,
57 runner_label: "runner/provider",
58 request_path: config.profile.chat_path,
59 endpoint: config.endpoint,
60 api_key_env: config.api_key_env,
61 auth,
62 codec: config.codec,
63 timeout: config.timeout,
64 stream: config.stream,
65 tools: config.tools,
66 max_response_bytes: config.max_output_bytes,
67 grammar_dialects: config.grammar_dialects,
68 }
69 }
70
71 #[allow(clippy::too_many_arguments)]
75 pub fn new_openai_compatible(
76 runner: Symbol,
77 model: impl Into<String>,
78 endpoint: impl Into<String>,
79 api_key_env: impl Into<String>,
80 codec: Symbol,
81 timeout: Duration,
82 stream: bool,
83 tools: bool,
84 max_response_bytes: usize,
85 ) -> Self {
86 let api_key_env = api_key_env.into();
87 Self {
88 runner,
89 model: model.into(),
90 provider: Symbol::new("openai-compatible"),
91 locality: Symbol::new("network"),
92 runner_label: "runner/openai-compatible",
93 request_path: "/chat/completions",
94 endpoint: endpoint.into(),
95 api_key_env: Some(api_key_env.clone()),
96 auth: ProviderAuth::BearerEnv { env: api_key_env },
97 codec,
98 timeout,
99 stream,
100 tools,
101 max_response_bytes,
102 grammar_dialects: Vec::new(),
103 }
104 }
105
106 #[allow(clippy::too_many_arguments)]
108 pub fn new_ollama(
109 runner: Symbol,
110 model: impl Into<String>,
111 locality: Symbol,
112 endpoint: impl Into<String>,
113 codec: Symbol,
114 timeout: Duration,
115 stream: bool,
116 tools: bool,
117 max_response_bytes: usize,
118 ) -> Self {
119 Self {
120 runner,
121 model: model.into(),
122 provider: Symbol::new("ollama"),
123 locality,
124 runner_label: "runner/ollama",
125 request_path: "/api/chat",
126 endpoint: endpoint.into(),
127 api_key_env: None,
128 auth: ProviderAuth::None,
129 codec,
130 timeout,
131 stream,
132 tools,
133 max_response_bytes,
134 grammar_dialects: vec![GrammarDialect::Gbnf],
135 }
136 }
137
138 fn infer_inner(&self, cx: &mut Cx, request: ModelRequest) -> Result<ModelResponse> {
139 let include_raw = self.include_raw(cx, &request);
140 let api_key = self.api_key()?;
141 let headers = self.request_headers(api_key.as_deref());
142 let body = self.encode_request(request, self.stream)?;
143 let response = post_json(
144 HttpRunnerRequest {
145 runner_label: self.runner_label,
146 endpoint: self.endpoint.as_str(),
147 path: self.request_path,
148 headers,
149 timeout: self.timeout,
150 body,
151 max_response_bytes: self.max_response_bytes,
152 },
153 api_key.as_deref(),
154 )?;
155 self.decode_response(&response.body, include_raw)
156 }
157
158 fn infer_stream_inner(
159 &self,
160 cx: &mut Cx,
161 request: ModelRequest,
162 sink: &mut dyn ModelEventSink,
163 ) -> Result<ModelResponse> {
164 if !self.stream {
165 let response = self.infer_inner(cx, request)?;
166 sink.emit(ModelEvent::final_of(&response))?;
167 return Ok(response);
168 }
169 let include_raw = self.include_raw(cx, &request);
170 let api_key = self.api_key()?;
171 let headers = self.request_headers(api_key.as_deref());
172 let body = self.encode_request(request, true)?;
173 let mut decoder = self.stream_decoder(include_raw)?;
174 sink.emit(decoder.start_event())?;
175 let response = post_json_stream(
176 HttpRunnerRequest {
177 runner_label: self.runner_label,
178 endpoint: self.endpoint.as_str(),
179 path: self.request_path,
180 headers,
181 timeout: self.timeout,
182 body,
183 max_response_bytes: self.max_response_bytes,
184 },
185 api_key.as_deref(),
186 &mut |chunk| decoder.feed(chunk, sink),
187 )?;
188 let model_response = if decoder.has_stream_output() {
189 decoder.finish(sink)?
190 } else {
191 self.decode_response(&response.body, include_raw)?
192 };
193 sink.emit(ModelEvent::final_of(&model_response))?;
194 Ok(model_response)
195 }
196
197 fn encode_request(&self, request: ModelRequest, stream: bool) -> Result<Vec<u8>> {
198 let openai_codec = Symbol::qualified("codec", "openai");
199 let anthropic_codec = Symbol::qualified("codec", "anthropic");
200 let ollama_codec = Symbol::qualified("codec", "ollama");
201 let lm_studio_codec = Symbol::qualified("codec", "lm-studio");
202 let lemonade_codec = Symbol::qualified("codec", "lemonade");
203 let request = self.prepare_output_grammar(request);
204 let request_extra = request.extra.clone();
205 let request_expr: Expr = request.into();
206 let body = if self.codec == openai_codec {
207 encode_openai_request(
208 &request_expr,
209 &OpenAiRequestOptions::new(self.model.clone(), stream, self.tools),
210 )
211 } else if self.codec == anthropic_codec {
212 encode_anthropic_request(
213 &request_expr,
214 &AnthropicRequestOptions::new(
215 self.model.clone(),
216 DEFAULT_ANTHROPIC_MAX_TOKENS,
217 stream,
218 self.tools,
219 ),
220 )
221 } else if self.codec == ollama_codec {
222 encode_ollama_request(
223 &request_expr,
224 &OllamaRequestOptions::new(self.model.clone(), stream, self.tools),
225 )
226 } else if self.codec == lm_studio_codec {
227 encode_lm_studio_request(
228 &request_expr,
229 &LmStudioRequestOptions::new(self.model.clone(), stream, self.tools),
230 )
231 } else if self.codec == lemonade_codec {
232 encode_lemonade_request(
233 &request_expr,
234 &LemonadeRequestOptions::new(self.model.clone(), stream, self.tools),
235 )
236 } else {
237 Err(Error::Eval(format!(
238 "{} unsupported codec {}",
239 self.runner_label, self.codec
240 )))
241 }?;
242 attach_bridge_model_params_to_body(&self.codec, &request_extra, body, self.runner_label)
243 }
244
245 fn api_key(&self) -> Result<Option<String>> {
246 match &self.api_key_env {
247 Some(api_key_env) => Ok(Some(std::env::var(api_key_env).map_err(|_| {
248 Error::Eval(format!(
249 "{} missing env var {}",
250 self.runner_label, api_key_env
251 ))
252 })?)),
253 None => Ok(None),
254 }
255 }
256
257 fn request_headers(&self, secret: Option<&str>) -> Vec<(String, String)> {
258 if self.provider == Symbol::new("anthropic")
259 && matches!(self.auth, ProviderAuth::HeaderEnv { .. })
260 && let Some(secret) = secret
261 {
262 return anthropic_headers(secret);
263 }
264
265 let mut headers = vec![content_type_header()];
266 match (&self.auth, secret) {
267 (
268 ProviderAuth::BearerEnv { .. } | ProviderAuth::OptionalBearerEnv { .. },
269 Some(secret),
270 ) => {
271 headers.push(("Authorization".to_owned(), format!("Bearer {secret}")));
272 }
273 (ProviderAuth::HeaderEnv { header, .. }, Some(secret)) => {
274 headers.push((header.clone(), secret.to_owned()));
275 }
276 _ => {}
277 }
278 if self.provider == Symbol::new("anthropic") {
279 headers.push(("anthropic-version".to_owned(), ANTHROPIC_VERSION.to_owned()));
280 }
281 headers
282 }
283
284 fn decode_response(&self, body: &[u8], include_raw: bool) -> Result<ModelResponse> {
285 let openai_codec = Symbol::qualified("codec", "openai");
286 let anthropic_codec = Symbol::qualified("codec", "anthropic");
287 let ollama_codec = Symbol::qualified("codec", "ollama");
288 let lm_studio_codec = Symbol::qualified("codec", "lm-studio");
289 let lemonade_codec = Symbol::qualified("codec", "lemonade");
290 let expr = if self.codec == openai_codec {
291 decode_openai_response(self.runner.clone(), &self.model, body, include_raw)?
292 } else if self.codec == anthropic_codec {
293 if self.stream {
294 decode_anthropic_stream(self.runner.clone(), &self.model, body, include_raw)?
295 } else {
296 decode_anthropic_response(self.runner.clone(), &self.model, body, include_raw)?
297 }
298 } else if self.codec == ollama_codec {
299 if self.stream {
300 decode_ollama_stream(self.runner.clone(), &self.model, body, include_raw)?
301 } else {
302 decode_ollama_response(self.runner.clone(), &self.model, body, include_raw)?
303 }
304 } else if self.codec == lm_studio_codec {
305 if self.stream {
306 decode_lm_studio_stream(self.runner.clone(), &self.model, body, include_raw)?
307 } else {
308 decode_lm_studio_response(self.runner.clone(), &self.model, body, include_raw)?
309 }
310 } else if self.codec == lemonade_codec {
311 if self.stream {
312 decode_lemonade_stream(self.runner.clone(), &self.model, body, include_raw)?
313 } else {
314 decode_lemonade_response(self.runner.clone(), &self.model, body, include_raw)?
315 }
316 } else {
317 unreachable!("codec checked above")
318 };
319 ModelResponse::try_from(expr)
320 }
321
322 fn include_raw(&self, cx: &mut Cx, request: &ModelRequest) -> bool {
323 cx.require(&CapabilityName::new("ai-runner-raw-log"))
324 .is_ok()
325 && !request_privacy_no_raw(request)
326 }
327
328 fn direct_capabilities(&self) -> Vec<CapabilityName> {
329 let mut capabilities = vec![CapabilityName::new(AI_RUNNER_CAPABILITY)];
330 if self.locality == Symbol::new("local") {
331 capabilities.push(CapabilityName::new(AI_RUNNER_LOCAL_CAPABILITY));
332 } else {
333 capabilities.push(CapabilityName::new(AI_RUNNER_NETWORK_CAPABILITY));
334 }
335 if self.api_key_env.is_some() {
336 capabilities.push(CapabilityName::new(AI_RUNNER_SECRET_CAPABILITY));
337 }
338 capabilities
339 }
340
341 fn stream_decoder(&self, include_raw: bool) -> Result<HttpStreamDecoder> {
342 let openai_codec = Symbol::qualified("codec", "openai");
343 let anthropic_codec = Symbol::qualified("codec", "anthropic");
344 let ollama_codec = Symbol::qualified("codec", "ollama");
345 let lm_studio_codec = Symbol::qualified("codec", "lm-studio");
346 let lemonade_codec = Symbol::qualified("codec", "lemonade");
347 if self.codec == openai_codec {
348 Ok(HttpStreamDecoder::openai(
349 self.runner.clone(),
350 self.model.clone(),
351 include_raw,
352 ))
353 } else if self.codec == anthropic_codec {
354 Ok(HttpStreamDecoder::anthropic(
355 self.runner.clone(),
356 self.model.clone(),
357 include_raw,
358 ))
359 } else if self.codec == ollama_codec {
360 Ok(HttpStreamDecoder::ollama(
361 self.runner.clone(),
362 self.model.clone(),
363 include_raw,
364 ))
365 } else if self.codec == lm_studio_codec || self.codec == lemonade_codec {
366 Ok(HttpStreamDecoder::openai(
367 self.runner.clone(),
368 self.model.clone(),
369 include_raw,
370 ))
371 } else {
372 Err(Error::Eval(format!(
373 "{} unsupported codec {}",
374 self.runner_label, self.codec
375 )))
376 }
377 }
378
379 fn error_response(&self, message: impl Into<String>) -> Result<ModelResponse> {
380 ModelResponse::try_from(model_error_expr(
381 self.runner.clone(),
382 self.model.clone(),
383 message.into(),
384 ))
385 }
386
387 fn prepare_output_grammar(&self, mut request: ModelRequest) -> ModelRequest {
388 let Some(dialect) = self.preferred_grammar_dialect() else {
389 strip_output_grammar(&mut request.extra);
390 return request;
391 };
392 if extra_field(&request.extra, RETURN_SHAPE_EXTRA).is_none()
393 && !explicit_output_grammar_matches(&request.extra, dialect)
394 {
395 strip_output_grammar(&mut request.extra);
396 return request;
397 }
398 let return_codec = extra_symbol(&request.extra, RETURN_CODEC_EXTRA);
399 if return_codec.as_ref() != Some(&Symbol::qualified("codec", "json")) {
400 strip_output_grammar(&mut request.extra);
401 return request;
402 }
403 if !explicit_output_grammar_matches(&request.extra, dialect) {
404 remove_extra(&mut request.extra, OUTPUT_GRAMMAR_EXTRA);
405 }
406 normalize_return_shape_for_output_grammar(&mut request.extra);
407 upsert_extra(
408 &mut request.extra,
409 OUTPUT_GRAMMAR_DIALECT_EXTRA,
410 Expr::Symbol(grammar_dialect_symbol(dialect)),
411 );
412 request
413 }
414
415 fn preferred_grammar_dialect(&self) -> Option<GrammarDialect> {
416 if self.grammar_dialects.contains(&GrammarDialect::JsonSchema) {
417 Some(GrammarDialect::JsonSchema)
418 } else if self.grammar_dialects.contains(&GrammarDialect::Gbnf) {
419 Some(GrammarDialect::Gbnf)
420 } else {
421 None
422 }
423 }
424}
425
426const ANTHROPIC_VERSION: &str = "2023-06-01";
427const DEFAULT_ANTHROPIC_MAX_TOKENS: u64 = 1024;
428const AI_RUNNER_CAPABILITY: &str = "ai-runner";
429const AI_RUNNER_NETWORK_CAPABILITY: &str = "ai-runner-network";
430const AI_RUNNER_LOCAL_CAPABILITY: &str = "ai-runner-local";
431const AI_RUNNER_SECRET_CAPABILITY: &str = "ai-runner-secret";
432
433fn anthropic_headers(secret: &str) -> Vec<(String, String)> {
434 vec![
435 ("x-api-key".to_owned(), secret.to_owned()),
436 ("anthropic-version".to_owned(), ANTHROPIC_VERSION.to_owned()),
437 content_type_header(),
438 ]
439}
440
441fn content_type_header() -> (String, String) {
442 ("content-type".to_owned(), "application/json".to_owned())
443}
444
445fn request_privacy_no_raw(request: &ModelRequest) -> bool {
446 request
447 .extra
448 .iter()
449 .find_map(|(key, value)| is_field(key, "privacy").then_some(value))
450 .is_some_and(privacy_expr_no_raw)
451}
452
453fn privacy_expr_no_raw(expr: &Expr) -> bool {
454 match expr {
455 Expr::Symbol(symbol) => symbol.name.as_ref() == "no-raw",
456 Expr::String(text) => text == "no-raw",
457 Expr::List(items) | Expr::Vector(items) | Expr::Set(items) => {
458 items.iter().any(privacy_expr_no_raw)
459 }
460 Expr::Map(entries) => entries.iter().any(|(key, value)| {
461 is_field(key, "no-raw") && !matches!(value, Expr::Bool(false) | Expr::Nil)
462 }),
463 _ => false,
464 }
465}
466
467fn is_field(expr: &Expr, name: &str) -> bool {
468 matches!(
469 expr,
470 Expr::Symbol(symbol) if symbol.namespace.is_none() && symbol.name.as_ref() == name
471 )
472}
473
474fn extra_field<'a>(entries: &'a [(Expr, Expr)], name: &str) -> Option<&'a Expr> {
475 entries.iter().find_map(|(key, value)| {
476 if is_field(key, name) {
477 Some(value)
478 } else {
479 None
480 }
481 })
482}
483
484fn extra_field_mut<'a>(entries: &'a mut [(Expr, Expr)], name: &str) -> Option<&'a mut Expr> {
485 entries.iter_mut().find_map(|(key, value)| {
486 if is_field(key, name) {
487 Some(value)
488 } else {
489 None
490 }
491 })
492}
493
494fn extra_symbol(entries: &[(Expr, Expr)], name: &str) -> Option<Symbol> {
495 match extra_field(entries, name) {
496 Some(Expr::Symbol(symbol)) => Some(symbol.clone()),
497 _ => None,
498 }
499}
500
501fn upsert_extra(entries: &mut Vec<(Expr, Expr)>, name: &str, value: Expr) {
502 if let Some((_, existing)) = entries.iter_mut().find(|(key, _)| is_field(key, name)) {
503 *existing = value;
504 return;
505 }
506 entries.push((Expr::Symbol(Symbol::new(name)), value));
507}
508
509fn strip_output_grammar(entries: &mut Vec<(Expr, Expr)>) {
510 entries.retain(|(key, _)| {
511 !is_field(key, OUTPUT_GRAMMAR_EXTRA)
512 && !is_field(key, OUTPUT_GRAMMAR_DIALECT_EXTRA)
513 && !is_field(key, OUTPUT_GRAMMAR_REQUIRED_EXTRA)
514 && !is_field(key, RETURN_SHAPE_EXTRA)
515 });
516}
517
518fn remove_extra(entries: &mut Vec<(Expr, Expr)>, name: &str) {
519 entries.retain(|(key, _)| !is_field(key, name));
520}
521
522fn normalize_return_shape_for_output_grammar(entries: &mut [(Expr, Expr)]) {
523 let Some(shape_expr) = extra_field_mut(entries, RETURN_SHAPE_EXTRA) else {
524 return;
525 };
526 let Expr::Symbol(symbol) = shape_expr else {
527 return;
528 };
529 if symbol.namespace.as_deref() != Some("core") {
530 return;
531 }
532 if matches!(
533 symbol.name.as_ref(),
534 "Any" | "Bool" | "List" | "Map" | "Nil" | "Number" | "String" | "Symbol"
535 ) {
536 *shape_expr = Expr::Symbol(Symbol::new(symbol.name.to_string()));
537 }
538}
539
540fn explicit_output_grammar_matches(entries: &[(Expr, Expr)], dialect: GrammarDialect) -> bool {
541 matches!(
542 extra_field(entries, OUTPUT_GRAMMAR_EXTRA),
543 Some(Expr::String(_))
544 ) && extra_field(entries, OUTPUT_GRAMMAR_DIALECT_EXTRA)
545 .and_then(|expr| match expr {
546 Expr::Symbol(symbol) => grammar_dialect_from_symbol_local(symbol),
547 _ => None,
548 })
549 .unwrap_or(GrammarDialect::JsonSchema)
550 == dialect
551}
552
553fn grammar_dialect_from_symbol_local(symbol: &Symbol) -> Option<GrammarDialect> {
554 match symbol.name.as_ref() {
555 "json-schema" if symbol.namespace.is_none() => Some(GrammarDialect::JsonSchema),
556 "gbnf" if symbol.namespace.is_none() => Some(GrammarDialect::Gbnf),
557 "sexpr" if symbol.namespace.is_none() => Some(GrammarDialect::SExpr),
558 _ => None,
559 }
560}
561
562impl ModelRunner for HttpRunner {
563 fn card(&self) -> ModelCard {
564 let mut card = ModelCard::new(
565 self.runner.clone(),
566 self.model.clone(),
567 self.provider.clone(),
568 self.locality.clone(),
569 );
570 if !self.grammar_dialects.is_empty() {
571 card.extra.push((
572 Expr::Symbol(Symbol::new("output-grammar-dialects")),
573 Expr::Vector(
574 self.grammar_dialects
575 .iter()
576 .copied()
577 .map(grammar_dialect_symbol)
578 .map(Expr::Symbol)
579 .collect(),
580 ),
581 ));
582 }
583 card
584 }
585
586 fn infer(&self, cx: &mut Cx, request: ModelRequest) -> Result<ModelResponse> {
587 match self.resolve_network_effect(cx, request, |runner, cx, request| {
588 runner.infer_inner(cx, request)
589 }) {
590 Ok(response) => Ok(response),
591 Err(error) => self.error_response(redact_text(&error.to_string(), &[])),
592 }
593 }
594
595 fn infer_stream(
596 &self,
597 cx: &mut Cx,
598 request: ModelRequest,
599 sink: &mut dyn ModelEventSink,
600 ) -> Result<ModelResponse> {
601 match self.resolve_network_effect(cx, request, {
602 let sink = &mut *sink;
603 |runner, cx, request| runner.infer_stream_inner(cx, request, sink)
604 }) {
605 Ok(response) => Ok(response),
606 Err(error) => {
607 let message = redact_text(&error.to_string(), &[]);
608 sink.emit(ModelEvent::error_text(
609 self.runner.clone(),
610 self.model.clone(),
611 Expr::String("http-stream-error".to_owned()),
612 message.clone(),
613 ))?;
614 let response = self.error_response(message)?;
615 sink.emit(ModelEvent::final_of(&response))?;
616 Ok(response)
617 }
618 }
619 }
620}
621
622impl HttpRunner {
623 fn resolve_network_effect<F>(
624 &self,
625 cx: &mut Cx,
626 request: ModelRequest,
627 perform: F,
628 ) -> Result<ModelResponse>
629 where
630 F: FnOnce(&Self, &mut Cx, ModelRequest) -> Result<ModelResponse>,
631 {
632 let effect = self.network_effect(cx, &request)?;
633 let result = effect::resolve_effect(cx, effect, |cx, _effect| {
634 let response = perform(self, cx, request)?;
635 response_ref(cx, response)
636 })?;
637 response_from_ref(cx, &result)
638 }
639
640 fn network_effect(&self, cx: &mut Cx, request: &ModelRequest) -> Result<Effect> {
641 let input = Datum::Node {
642 tag: Symbol::qualified("agent", "HttpRunnerInput"),
643 fields: vec![
644 (Symbol::new("runner"), Datum::Symbol(self.runner.clone())),
645 (Symbol::new("model"), Datum::String(self.model.clone())),
646 (
647 Symbol::new("provider"),
648 Datum::Symbol(self.provider.clone()),
649 ),
650 (
651 Symbol::new("endpoint"),
652 Datum::String(self.endpoint.clone()),
653 ),
654 (
655 Symbol::new("request"),
656 Datum::try_from(Expr::from(request.clone()))?,
657 ),
658 ],
659 };
660 let input = Ref::Content(cx.datum_store_mut().intern(input)?);
661 Effect::new(
662 network_effect_kind(),
663 Ref::Symbol(self.runner.clone()),
664 input,
665 core_any_ref(),
666 effect::effect_resume_op_key(),
667 effect::effect_abort_op_key(),
668 )
669 .with_requirements(self.direct_capabilities())
670 .with_replay_key(Some(Ref::Symbol(Symbol::qualified(
671 "agent",
672 "http-runner-v1",
673 ))))
674 }
675}
676
677fn network_effect_kind() -> Symbol {
678 Symbol::qualified("effect", "network")
679}
680
681fn response_ref(cx: &mut Cx, response: ModelResponse) -> Result<Ref> {
682 Ok(Ref::Content(
683 cx.datum_store_mut()
684 .intern(Datum::try_from(Expr::from(response))?)?,
685 ))
686}
687
688fn response_from_ref(cx: &mut Cx, reference: &Ref) -> Result<ModelResponse> {
689 ModelResponse::try_from(value_from_ref(cx, reference)?.object().as_expr(cx)?)
690}
691
692#[cfg(test)]
693mod tests;