Skip to main content

sim_lib_agent_runner_http/
runner.rs

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/// HTTP-backed [`ModelRunner`] for OpenAI-compatible and Ollama endpoints.
28#[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    /// Builds a runner from an open provider config without changing the HTTP
49    /// transport path.
50    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    /// Builds a runner targeting an OpenAI-compatible `/chat/completions`
72    /// endpoint, reading its API key from the `api_key_env` environment
73    /// variable.
74    #[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    /// Builds a runner targeting an Ollama endpoint at the given `locality`.
107    #[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;