Skip to main content

sim_lib_agent_runner_http/
runner.rs

1use crate::client::{HttpRunnerRequest, post_json, post_json_stream};
2use crate::redact::redact_text;
3use crate::stream::HttpStreamDecoder;
4use sim_codec_chat::{
5    OllamaRequestOptions, decode_ollama_response, decode_ollama_stream, encode_ollama_request,
6    model_error_expr,
7};
8use sim_kernel::{
9    CapabilityName, Cx, Datum, DatumStore, Effect, Error, Expr, Ref, Result, Symbol, core_any_ref,
10    effect, value_from_ref,
11};
12use sim_lib_agent_runner_core::{
13    ModelCard, ModelEvent, ModelEventSink, ModelRequest, ModelResponse, ModelRunner,
14};
15use sim_lib_openai_server::{OpenAiRequestOptions, decode_openai_response, encode_openai_request};
16use std::time::Duration;
17
18/// HTTP-backed [`ModelRunner`] for OpenAI-compatible and Ollama endpoints.
19#[derive(Clone, Debug)]
20pub struct HttpRunner {
21    runner: Symbol,
22    model: String,
23    provider: Symbol,
24    locality: Symbol,
25    runner_label: &'static str,
26    request_path: &'static str,
27    endpoint: String,
28    api_key_env: Option<String>,
29    codec: Symbol,
30    timeout: Duration,
31    stream: bool,
32    tools: bool,
33    max_response_bytes: usize,
34}
35
36impl HttpRunner {
37    /// Builds a runner targeting an OpenAI-compatible `/chat/completions`
38    /// endpoint, reading its API key from the `api_key_env` environment
39    /// variable.
40    #[allow(clippy::too_many_arguments)]
41    pub fn new_openai_compatible(
42        runner: Symbol,
43        model: impl Into<String>,
44        endpoint: impl Into<String>,
45        api_key_env: impl Into<String>,
46        codec: Symbol,
47        timeout: Duration,
48        stream: bool,
49        tools: bool,
50        max_response_bytes: usize,
51    ) -> Self {
52        Self {
53            runner,
54            model: model.into(),
55            provider: Symbol::new("openai-compatible"),
56            locality: Symbol::new("network"),
57            runner_label: "runner/openai-compatible",
58            request_path: "/chat/completions",
59            endpoint: endpoint.into(),
60            api_key_env: Some(api_key_env.into()),
61            codec,
62            timeout,
63            stream,
64            tools,
65            max_response_bytes,
66        }
67    }
68
69    /// Builds a runner targeting an Ollama endpoint at the given `locality`.
70    #[allow(clippy::too_many_arguments)]
71    pub fn new_ollama(
72        runner: Symbol,
73        model: impl Into<String>,
74        locality: Symbol,
75        endpoint: impl Into<String>,
76        codec: Symbol,
77        timeout: Duration,
78        stream: bool,
79        tools: bool,
80        max_response_bytes: usize,
81    ) -> Self {
82        Self {
83            runner,
84            model: model.into(),
85            provider: Symbol::new("ollama"),
86            locality,
87            runner_label: "runner/ollama",
88            request_path: "/api/chat",
89            endpoint: endpoint.into(),
90            api_key_env: None,
91            codec,
92            timeout,
93            stream,
94            tools,
95            max_response_bytes,
96        }
97    }
98
99    fn infer_inner(&self, cx: &mut Cx, request: ModelRequest) -> Result<ModelResponse> {
100        let include_raw = self.include_raw(cx, &request);
101        let body = self.encode_request(request, self.stream)?;
102        let api_key = self.api_key()?;
103        let response = post_json(
104            HttpRunnerRequest {
105                runner_label: self.runner_label,
106                endpoint: self.endpoint.as_str(),
107                path: self.request_path,
108                bearer_token: api_key.as_deref(),
109                timeout: self.timeout,
110                body,
111                max_response_bytes: self.max_response_bytes,
112            },
113            api_key.as_deref(),
114        )?;
115        self.decode_response(&response.body, include_raw)
116    }
117
118    fn infer_stream_inner(
119        &self,
120        cx: &mut Cx,
121        request: ModelRequest,
122        sink: &mut dyn ModelEventSink,
123    ) -> Result<ModelResponse> {
124        if !self.stream {
125            let response = self.infer_inner(cx, request)?;
126            sink.emit(ModelEvent::final_of(&response))?;
127            return Ok(response);
128        }
129        let include_raw = self.include_raw(cx, &request);
130        let body = self.encode_request(request, true)?;
131        let api_key = self.api_key()?;
132        let mut decoder = self.stream_decoder(include_raw)?;
133        sink.emit(decoder.start_event())?;
134        let response = post_json_stream(
135            HttpRunnerRequest {
136                runner_label: self.runner_label,
137                endpoint: self.endpoint.as_str(),
138                path: self.request_path,
139                bearer_token: api_key.as_deref(),
140                timeout: self.timeout,
141                body,
142                max_response_bytes: self.max_response_bytes,
143            },
144            api_key.as_deref(),
145            &mut |chunk| decoder.feed(chunk, sink),
146        )?;
147        let model_response = if decoder.has_stream_output() {
148            decoder.finish(sink)?
149        } else {
150            self.decode_response(&response.body, include_raw)?
151        };
152        sink.emit(ModelEvent::final_of(&model_response))?;
153        Ok(model_response)
154    }
155
156    fn encode_request(&self, request: ModelRequest, stream: bool) -> Result<Vec<u8>> {
157        let openai_codec = Symbol::qualified("codec", "openai");
158        let ollama_codec = Symbol::qualified("codec", "ollama");
159        let request_expr: Expr = request.into();
160        if self.codec == openai_codec {
161            encode_openai_request(
162                &request_expr,
163                &OpenAiRequestOptions::new(self.model.clone(), stream, self.tools),
164            )
165        } else if self.codec == ollama_codec {
166            encode_ollama_request(
167                &request_expr,
168                &OllamaRequestOptions::new(self.model.clone(), stream, self.tools),
169            )
170        } else {
171            Err(Error::Eval(format!(
172                "{} unsupported codec {}",
173                self.runner_label, self.codec
174            )))
175        }
176    }
177
178    fn api_key(&self) -> Result<Option<String>> {
179        match &self.api_key_env {
180            Some(api_key_env) => Ok(Some(std::env::var(api_key_env).map_err(|_| {
181                Error::Eval(format!(
182                    "{} missing env var {}",
183                    self.runner_label, api_key_env
184                ))
185            })?)),
186            None => Ok(None),
187        }
188    }
189
190    fn decode_response(&self, body: &[u8], include_raw: bool) -> Result<ModelResponse> {
191        let openai_codec = Symbol::qualified("codec", "openai");
192        let ollama_codec = Symbol::qualified("codec", "ollama");
193        let expr = if self.codec == openai_codec {
194            decode_openai_response(self.runner.clone(), &self.model, body, include_raw)?
195        } else if self.codec == ollama_codec {
196            if self.stream {
197                decode_ollama_stream(self.runner.clone(), &self.model, body, include_raw)?
198            } else {
199                decode_ollama_response(self.runner.clone(), &self.model, body, include_raw)?
200            }
201        } else {
202            unreachable!("codec checked above")
203        };
204        ModelResponse::try_from(expr)
205    }
206
207    fn include_raw(&self, cx: &mut Cx, request: &ModelRequest) -> bool {
208        cx.require(&CapabilityName::new("ai-runner-raw-log"))
209            .is_ok()
210            && !request_privacy_no_raw(request)
211    }
212
213    fn stream_decoder(&self, include_raw: bool) -> Result<HttpStreamDecoder> {
214        let openai_codec = Symbol::qualified("codec", "openai");
215        let ollama_codec = Symbol::qualified("codec", "ollama");
216        if self.codec == openai_codec {
217            Ok(HttpStreamDecoder::openai(
218                self.runner.clone(),
219                self.model.clone(),
220                include_raw,
221            ))
222        } else if self.codec == ollama_codec {
223            Ok(HttpStreamDecoder::ollama(
224                self.runner.clone(),
225                self.model.clone(),
226                include_raw,
227            ))
228        } else {
229            Err(Error::Eval(format!(
230                "{} unsupported codec {}",
231                self.runner_label, self.codec
232            )))
233        }
234    }
235
236    fn error_response(&self, message: impl Into<String>) -> Result<ModelResponse> {
237        ModelResponse::try_from(model_error_expr(
238            self.runner.clone(),
239            self.model.clone(),
240            message.into(),
241        ))
242    }
243}
244
245fn request_privacy_no_raw(request: &ModelRequest) -> bool {
246    request
247        .extra
248        .iter()
249        .find_map(|(key, value)| is_field(key, "privacy").then_some(value))
250        .is_some_and(privacy_expr_no_raw)
251}
252
253fn privacy_expr_no_raw(expr: &Expr) -> bool {
254    match expr {
255        Expr::Symbol(symbol) => symbol.name.as_ref() == "no-raw",
256        Expr::String(text) => text == "no-raw",
257        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) => {
258            items.iter().any(privacy_expr_no_raw)
259        }
260        Expr::Map(entries) => entries.iter().any(|(key, value)| {
261            is_field(key, "no-raw") && !matches!(value, Expr::Bool(false) | Expr::Nil)
262        }),
263        _ => false,
264    }
265}
266
267fn is_field(expr: &Expr, name: &str) -> bool {
268    matches!(
269        expr,
270        Expr::Symbol(symbol) if symbol.namespace.is_none() && symbol.name.as_ref() == name
271    )
272}
273
274impl ModelRunner for HttpRunner {
275    fn card(&self) -> ModelCard {
276        ModelCard::new(
277            self.runner.clone(),
278            self.model.clone(),
279            self.provider.clone(),
280            self.locality.clone(),
281        )
282    }
283
284    fn infer(&self, cx: &mut Cx, request: ModelRequest) -> Result<ModelResponse> {
285        match self.resolve_network_effect(cx, request, |runner, cx, request| {
286            runner.infer_inner(cx, request)
287        }) {
288            Ok(response) => Ok(response),
289            Err(error) => self.error_response(redact_text(&error.to_string(), &[])),
290        }
291    }
292
293    fn infer_stream(
294        &self,
295        cx: &mut Cx,
296        request: ModelRequest,
297        sink: &mut dyn ModelEventSink,
298    ) -> Result<ModelResponse> {
299        match self.resolve_network_effect(cx, request, {
300            let sink = &mut *sink;
301            |runner, cx, request| runner.infer_stream_inner(cx, request, sink)
302        }) {
303            Ok(response) => Ok(response),
304            Err(error) => {
305                let message = redact_text(&error.to_string(), &[]);
306                sink.emit(ModelEvent::error_text(
307                    self.runner.clone(),
308                    self.model.clone(),
309                    Expr::String("http-stream-error".to_owned()),
310                    message.clone(),
311                ))?;
312                let response = self.error_response(message)?;
313                sink.emit(ModelEvent::final_of(&response))?;
314                Ok(response)
315            }
316        }
317    }
318}
319
320impl HttpRunner {
321    fn resolve_network_effect<F>(
322        &self,
323        cx: &mut Cx,
324        request: ModelRequest,
325        perform: F,
326    ) -> Result<ModelResponse>
327    where
328        F: FnOnce(&Self, &mut Cx, ModelRequest) -> Result<ModelResponse>,
329    {
330        let effect = self.network_effect(cx, &request)?;
331        let result = effect::resolve_effect(cx, effect, |cx, _effect| {
332            let response = perform(self, cx, request)?;
333            response_ref(cx, response)
334        })?;
335        response_from_ref(cx, &result)
336    }
337
338    fn network_effect(&self, cx: &mut Cx, request: &ModelRequest) -> Result<Effect> {
339        let input = Datum::Node {
340            tag: Symbol::qualified("agent", "HttpRunnerInput"),
341            fields: vec![
342                (Symbol::new("runner"), Datum::Symbol(self.runner.clone())),
343                (Symbol::new("model"), Datum::String(self.model.clone())),
344                (
345                    Symbol::new("provider"),
346                    Datum::Symbol(self.provider.clone()),
347                ),
348                (
349                    Symbol::new("endpoint"),
350                    Datum::String(self.endpoint.clone()),
351                ),
352                (
353                    Symbol::new("request"),
354                    Datum::try_from(Expr::from(request.clone()))?,
355                ),
356            ],
357        };
358        let input = Ref::Content(cx.datum_store_mut().intern(input)?);
359        Effect::new(
360            effect::effect_network_kind(),
361            Ref::Symbol(self.runner.clone()),
362            input,
363            core_any_ref(),
364            effect::effect_resume_op_key(),
365            effect::effect_abort_op_key(),
366        )
367        .with_replay_key(Some(Ref::Symbol(Symbol::qualified(
368            "agent",
369            "http-runner-v1",
370        ))))
371    }
372}
373
374fn response_ref(cx: &mut Cx, response: ModelResponse) -> Result<Ref> {
375    Ok(Ref::Content(
376        cx.datum_store_mut()
377            .intern(Datum::try_from(Expr::from(response))?)?,
378    ))
379}
380
381fn response_from_ref(cx: &mut Cx, reference: &Ref) -> Result<ModelResponse> {
382    ModelResponse::try_from(value_from_ref(cx, reference)?.object().as_expr(cx)?)
383}