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#[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 #[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 #[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}