1pub mod actions;
3
4pub use actions::OpenApiClientProtocol;
5
6use anyhow::{anyhow, Context, Result};
7use std::collections::HashMap;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11use tracing::{debug, error, info};
12
13use crate::client::http_fetch::{FetchClient, FetchResponse};
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::openapi::actions::{
16 OPENAPI_CLIENT_CONNECTED_EVENT, OPENAPI_OPERATION_RESPONSE_EVENT,
17};
18use crate::llm::actions::client_trait::{Client, ClientActionResult};
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::logging::emit::Log;
22use crate::protocol::Event;
23use crate::state::app_state::AppState;
24use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
25use crate::state::{AccessLogOwner, ClientId, ClientStatus};
26
27use openapi_rs::model::parse::OpenAPI;
28
29pub struct OpenApiExchange {
35 pub operation_id: String,
36 pub method: String,
37 pub path: String,
38 pub status_code: u16,
39 pub status_text: String,
40 pub headers: serde_json::Map<String, serde_json::Value>,
41 pub body: String,
42}
43
44enum Applied {
46 Executed(String),
48 Disconnect,
50}
51
52#[derive(Clone, Copy)]
54enum Dispatch {
55 Spawn,
59 Await,
63}
64
65const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
69
70async fn read_body_bounded(mut response: FetchResponse) -> Result<String> {
75 let mut body = Vec::new();
76 while let Some(chunk) = response.chunk().await? {
77 if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
78 return Err(anyhow!(
79 "response body exceeded {} bytes; refusing to buffer the rest",
80 MAX_RESPONSE_BYTES
81 ));
82 }
83 body.extend_from_slice(&chunk);
84 }
85 Ok(String::from_utf8_lossy(&body).into_owned())
86}
87
88pub struct OpenApiClient;
90
91impl OpenApiClient {
92 #[cfg(not(target_arch = "wasm32"))]
100 async fn reqwest_client() -> Result<reqwest::Client> {
101 static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
102 if let Some(client) = CLIENT.get() {
103 return Ok(client.clone());
104 }
105 let built = tokio::task::spawn_blocking(|| {
106 reqwest::Client::builder()
107 .timeout(std::time::Duration::from_secs(30))
108 .use_rustls_tls()
109 .build()
110 .context("Failed to build HTTP client")
111 })
112 .await
113 .context("OpenAPI HTTP client build task panicked")??;
114 Ok(CLIENT.get_or_init(|| built).clone())
115 }
116
117 async fn http_client() -> Result<FetchClient> {
121 #[cfg(not(target_arch = "wasm32"))]
122 let client = FetchClient::from_reqwest(Self::reqwest_client().await?);
123 #[cfg(target_arch = "wasm32")]
124 let client = FetchClient::transport(std::time::Duration::from_secs(30));
125 Ok(client)
126 }
127
128 pub async fn connect_with_llm_actions(
130 remote_addr: String,
131 llm_client: OllamaClient,
132 app_state: Arc<AppState>,
133 status_tx: mpsc::UnboundedSender<String>,
134 client_id: ClientId,
135 startup_params: serde_json::Value,
136 ) -> Result<SocketAddr> {
137 info!(
138 "OpenAPI client {} initializing for {}",
139 client_id, remote_addr
140 );
141
142 let spec_yaml = if let Some(spec_str) = startup_params.get("spec").and_then(|v| v.as_str())
144 {
145 spec_str.to_string()
146 } else if let Some(spec_file) = startup_params.get("spec_file").and_then(|v| v.as_str()) {
147 std::fs::read_to_string(spec_file)
149 .with_context(|| format!("Failed to read spec file: {}", spec_file))?
150 } else {
151 return Err(anyhow!(
152 "OpenAPI client requires 'spec' or 'spec_file' parameter"
153 ));
154 };
155
156 let parsed_spec: OpenAPI =
158 serde_yaml::from_str(&spec_yaml).context("Failed to parse OpenAPI spec (YAML)")?;
159
160 let base_url =
175 if let Some(override_url) = startup_params.get("base_url").and_then(|v| v.as_str()) {
176 override_url.to_string()
177 } else if !remote_addr.trim().is_empty() {
178 let named = if remote_addr.contains("://") {
179 remote_addr.clone()
180 } else {
181 format!("http://{}", remote_addr)
184 };
185 if let Some(server) = parsed_spec.servers.first() {
186 if server.url != named {
187 info!(
188 "OpenAPI client {} using remote_addr {} rather than the spec's \
189 servers[0] ({}); pass base_url to override",
190 client_id, named, server.url
191 );
192 }
193 }
194 named
195 } else if let Some(server) = parsed_spec.servers.first() {
196 info!(
199 "OpenAPI client {} has no remote_addr; falling back to the spec's \
200 servers[0]: {}",
201 client_id, server.url
202 );
203 server.url.clone()
204 } else {
205 return Err(anyhow!(
206 "OpenAPI client needs a target: pass remote_addr, a base_url startup \
207 parameter, or a spec declaring servers[0]"
208 ));
209 };
210
211 info!("OpenAPI client {} using base URL: {}", client_id, base_url);
212
213 Self::http_client().await?;
217 crate::client::http_fetch::check_url(&base_url)?;
220
221 app_state
223 .with_client_mut(client_id, |client| {
224 client.set_protocol_field("spec".to_string(), serde_json::json!(spec_yaml));
225 client.set_protocol_field("base_url".to_string(), serde_json::json!(base_url));
226 client.set_protocol_field(
227 "http_client".to_string(),
228 serde_json::json!("initialized"),
229 );
230 })
231 .await;
232
233 app_state
235 .update_client_status(client_id, ClientStatus::Connected)
236 .await;
237 Log::new(Some(&status_tx)).info(format!(
238 "OpenAPI client {} ready for {}",
239 client_id, remote_addr
240 ));
241 let _ = status_tx.send("__UPDATE_UI__".to_string());
242
243 let command_rx =
248 crate::client::command_support::register_command_channel(&app_state, client_id).await;
249
250 let command_task = tokio::spawn(Self::command_loop(
256 command_rx,
257 client_id,
258 app_state.clone(),
259 llm_client.clone(),
260 status_tx.clone(),
261 ));
262 app_state
263 .register_client_task(client_id, command_task)
264 .await;
265
266 let operations = Self::extract_operations(&parsed_spec);
268
269 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
271 let spec_title = parsed_spec.info.title.clone();
272 let spec_version = parsed_spec.info.version.clone();
273
274 let event = Event::new(
275 &OPENAPI_CLIENT_CONNECTED_EVENT,
276 serde_json::json!({
277 "base_url": base_url.clone(),
278 "spec_title": spec_title,
279 "spec_version": spec_version,
280 "operation_count": operations.len(),
281 "operations": operations,
282 }),
283 );
284
285 match call_llm_for_client(
286 &llm_client,
287 &app_state,
288 client_id.to_string(),
289 &instruction,
290 &String::new(), Some(&event),
292 &crate::client::openapi::actions::OpenApiClientProtocol,
293 &status_tx,
294 )
295 .await
296 {
297 Ok(result) => {
298 Self::execute_llm_actions(
300 client_id,
301 result,
302 app_state.clone(),
303 llm_client.clone(),
304 status_tx.clone(),
305 )
306 .await;
307 }
308 Err(e) => {
309 error!("LLM error on openapi_client_connected event: {}", e);
310 }
311 }
312 }
313
314 Ok("0.0.0.0:0".parse().unwrap())
316 }
317
318 fn extract_operations(spec: &OpenAPI) -> Vec<serde_json::Value> {
320 let mut operations = Vec::new();
321
322 for (path, path_item) in &spec.paths {
323 for (method_str, operation) in &path_item.operations {
325 let operation_id = operation
326 .operation_id
327 .as_ref()
328 .cloned()
329 .unwrap_or_else(|| format!("{}_{}", method_str, path.replace('/', "_")));
330
331 operations.push(serde_json::json!({
332 "operation_id": operation_id,
333 "method": method_str.to_uppercase(),
334 "path": path,
335 "summary": operation.summary.as_ref().unwrap_or(&String::new()),
336 "description": operation.description.as_ref().unwrap_or(&String::new()),
337 }));
338 }
339 }
340
341 operations
342 }
343
344 async fn execute_llm_actions(
350 client_id: ClientId,
351 result: ClientLlmResult,
352 app_state: Arc<AppState>,
353 llm_client: OllamaClient,
354 status_tx: mpsc::UnboundedSender<String>,
355 ) {
356 let protocol = crate::client::openapi::actions::OpenApiClientProtocol;
357
358 for action in result.actions {
359 match protocol.execute_action(action.clone()) {
360 Ok(ClientActionResult::Disconnect) => {
361 info!("LLM requested disconnect for OpenAPI client {}", client_id);
362 }
365 Ok(applied) => {
366 match Self::apply_action(
368 client_id,
369 applied,
370 Dispatch::Spawn,
371 &app_state,
372 &llm_client,
373 &status_tx,
374 )
375 .await
376 {
377 Ok(Applied::Executed(detail)) => {
378 debug!("OpenAPI client {} after connect: {}", client_id, detail)
379 }
380 Ok(Applied::Disconnect) => {
381 info!("LLM requested disconnect for OpenAPI client {}", client_id)
382 }
383 Err(e) => error!("OpenAPI operation execution failed: {}", e),
384 }
385 }
386 Err(e) => {
387 error!("Action execution error: {}", e);
388 }
389 }
390 }
391 }
392
393 async fn apply_action(
403 client_id: ClientId,
404 result: ClientActionResult,
405 dispatch: Dispatch,
406 app_state: &Arc<AppState>,
407 llm_client: &OllamaClient,
408 status_tx: &mpsc::UnboundedSender<String>,
409 ) -> Result<Applied> {
410 match result {
411 ClientActionResult::Custom { name, data } if name == "openapi_operation" => {
412 let operation_id = data["operation_id"]
413 .as_str()
414 .unwrap_or_default()
415 .to_string();
416 let path_params: HashMap<String, String> = data["path_params"]
417 .as_object()
418 .map(|obj| {
419 obj.iter()
420 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
421 .collect()
422 })
423 .unwrap_or_default();
424 let query_params: HashMap<String, String> = data["query_params"]
425 .as_object()
426 .map(|obj| {
427 obj.iter()
428 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
429 .collect()
430 })
431 .unwrap_or_default();
432 let headers = data["headers"].as_object().cloned();
433 let body = data["body"].clone();
434
435 match dispatch {
436 Dispatch::Spawn => {
437 let state_clone = app_state.clone();
438 let llm_clone = llm_client.clone();
439 let status_clone = status_tx.clone();
440 let log_id = operation_id.clone();
441
442 let handle = tokio::spawn(async move {
445 if let Err(e) = Self::execute_operation(
446 client_id,
447 operation_id,
448 path_params,
449 query_params,
450 headers,
451 body,
452 state_clone,
453 llm_clone,
454 status_clone,
455 )
456 .await
457 {
458 error!("OpenAPI operation execution failed: {}", e);
459 }
460 });
461 app_state.register_client_task(client_id, handle).await;
462 Ok(Applied::Executed(format!("operation {log_id} dispatched")))
463 }
464 Dispatch::Await => {
465 let exchange = Self::perform_operation(
466 client_id,
467 operation_id,
468 path_params,
469 query_params,
470 headers,
471 body,
472 app_state,
473 status_tx,
474 )
475 .await?;
476 let detail = format!(
477 "operation {} -> {} {} -> {} ({} byte body)",
478 exchange.operation_id,
479 exchange.method,
480 exchange.path,
481 exchange.status_code,
482 exchange.body.len()
483 );
484
485 let state_clone = app_state.clone();
486 let llm_clone = llm_client.clone();
487 let status_clone = status_tx.clone();
488 let handle = tokio::spawn(async move {
489 Self::notify_operation_response(
490 client_id,
491 exchange,
492 state_clone,
493 llm_clone,
494 status_clone,
495 )
496 .await;
497 });
498 app_state.register_client_task(client_id, handle).await;
499 Ok(Applied::Executed(detail))
500 }
501 }
502 }
503 ClientActionResult::Custom { name, .. } => Ok(Applied::Executed(format!(
504 "unknown OpenAPI custom result '{name}' was not executed"
505 ))),
506 ClientActionResult::Disconnect => Ok(Applied::Disconnect),
507 ClientActionResult::NoAction => Ok(Applied::Executed(
508 "no_action (list_operations / get_operation_details are answered from the \
509 spec already sent in openapi_client_connected)"
510 .to_string(),
511 )),
512 ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
513 ClientActionResult::SendData(_) => Ok(Applied::Executed(
514 "OpenAPI owns no socket; raw send_data cannot be put on the wire".to_string(),
515 )),
516 ClientActionResult::Multiple(_) => Ok(Applied::Executed(
517 "OpenAPI produces no Multiple results; nothing executed".to_string(),
518 )),
519 }
520 }
521
522 async fn command_loop(
524 mut command_rx: mpsc::Receiver<ClientCommand>,
525 client_id: ClientId,
526 app_state: Arc<AppState>,
527 llm_client: OllamaClient,
528 status_tx: mpsc::UnboundedSender<String>,
529 ) {
530 use crate::llm::actions::protocol_trait::Protocol;
531 let protocol = crate::client::openapi::actions::OpenApiClientProtocol;
532
533 while let Some(command) = command_rx.recv().await {
534 let action = command.action.clone();
535 let outcome = match protocol.execute_action(action.clone()) {
536 Err(e) => Ok(ClientSendOutcome::Rejected {
537 error: e.to_string(),
538 }),
539 Ok(result) => match Self::apply_action(
543 client_id,
544 result,
545 Dispatch::Await,
546 &app_state,
547 &llm_client,
548 &status_tx,
549 )
550 .await
551 {
552 Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
553 Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
554 Err(e) => Err(e),
555 },
556 };
557
558 let outcome_json = match &outcome {
559 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
560 Err(e) => serde_json::json!({"error": e.to_string()}),
561 };
562 app_state
563 .record_access_log(
564 AccessLogOwner::Client(client_id.as_u32()),
565 protocol.protocol_name(),
566 None,
567 "injected_action",
568 action,
569 vec![outcome_json],
570 )
571 .await;
572
573 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
574 if let Err(e) = &outcome {
575 error!("OpenAPI client {} injected action failed: {}", client_id, e);
576 let _ = status_tx.send(format!(
577 "[WARN] Client {} injected action failed: {}",
578 client_id, e
579 ));
580 }
581 let _ = status_tx.send("__UPDATE_UI__".to_string());
582 crate::client::command_support::reply(command, outcome);
583
584 if disconnect {
585 app_state
586 .update_client_status(client_id, ClientStatus::Disconnected)
587 .await;
588 break;
589 }
590 }
591
592 info!("OpenAPI client {} command loop finished", client_id);
593 app_state.remove_client_handle(client_id).await;
594 let _ = status_tx.send("__UPDATE_UI__".to_string());
595 }
596
597 #[allow(clippy::too_many_arguments)]
599 async fn execute_operation(
600 client_id: ClientId,
601 operation_id: String,
602 path_params: HashMap<String, String>,
603 query_params: HashMap<String, String>,
604 header_overrides: Option<serde_json::Map<String, serde_json::Value>>,
605 body: serde_json::Value,
606 app_state: Arc<AppState>,
607 llm_client: OllamaClient,
608 status_tx: mpsc::UnboundedSender<String>,
609 ) -> Result<()> {
610 let exchange = Self::perform_operation(
611 client_id,
612 operation_id,
613 path_params,
614 query_params,
615 header_overrides,
616 body,
617 &app_state,
618 &status_tx,
619 )
620 .await?;
621 Self::notify_operation_response(client_id, exchange, app_state, llm_client, status_tx)
622 .await;
623 Ok(())
624 }
625
626 #[allow(clippy::too_many_arguments)]
632 async fn perform_operation(
633 client_id: ClientId,
634 operation_id: String,
635 path_params: HashMap<String, String>,
636 query_params: HashMap<String, String>,
637 header_overrides: Option<serde_json::Map<String, serde_json::Value>>,
638 body: serde_json::Value,
639 app_state: &AppState,
640 status_tx: &mpsc::UnboundedSender<String>,
641 ) -> Result<OpenApiExchange> {
642 let (spec_yaml, base_url) = app_state
644 .with_client_mut(client_id, |client| {
645 let spec = client
646 .get_protocol_field("spec")
647 .and_then(|v| v.as_str())
648 .map(|s| s.to_string());
649 let url = client
650 .get_protocol_field("base_url")
651 .and_then(|v| v.as_str())
652 .map(|s| s.to_string());
653 (spec, url)
654 })
655 .await
656 .unwrap_or((None, None));
657
658 let spec_yaml = spec_yaml.context("No spec found")?;
659 let base_url = base_url.context("No base URL found")?;
660
661 let parsed_spec: OpenAPI =
663 serde_yaml::from_str(&spec_yaml).context("Failed to parse OpenAPI spec")?;
664
665 let (path_template, method) = Self::find_operation(&parsed_spec, &operation_id)?;
667
668 let path = Self::substitute_path_params(&path_template, &path_params)?;
670
671 let url = format!("{}{}", base_url, path);
673
674 info!(
675 "OpenAPI client {} executing operation '{}': {} {}",
676 client_id, operation_id, method, url
677 );
678
679 let http_client = Self::http_client().await?;
680
681 let mut request = match method.to_uppercase().as_str() {
683 "GET" => http_client.get(&url),
684 "POST" => http_client.post(&url),
685 "PUT" => http_client.put(&url),
686 "DELETE" => http_client.delete(&url),
687 "HEAD" => http_client.head(&url),
688 "PATCH" => http_client.patch(&url),
689 _ => return Err(anyhow!("Unsupported HTTP method: {}", method)),
690 };
691
692 if !query_params.is_empty() {
694 request = request.query(&query_params);
695 }
696
697 if let Some(hdrs) = header_overrides {
699 for (key, value) in hdrs {
700 if let Some(val_str) = value.as_str() {
701 request = request.header(&key, val_str);
702 }
703 }
704 }
705
706 if !body.is_null() {
708 request = request.json(&body);
709 }
710
711 match request.send().await {
713 Ok(response) => {
714 let status = response.status();
715 let status_code = status.as_u16();
716
717 let mut resp_headers = serde_json::Map::new();
719 for (name, value) in response.headers() {
720 if let Ok(val_str) = value.to_str() {
721 resp_headers.insert(name.to_string(), serde_json::json!(val_str));
722 }
723 }
724
725 let body_text = match read_body_bounded(response).await {
732 Ok(text) => text,
733 Err(e) => {
734 Log::new(Some(status_tx)).warn(format!(
735 "OpenAPI client {} could not read the response for '{}': {}",
736 client_id, operation_id, e
737 ));
738 return Err(e);
739 }
740 };
741
742 info!(
743 "OpenAPI client {} received response for '{}': {} ({})",
744 client_id, operation_id, status_code, status
745 );
746
747 Ok(OpenApiExchange {
748 operation_id,
749 method,
750 path,
751 status_code,
752 status_text: status.to_string(),
753 headers: resp_headers,
754 body: body_text,
755 })
756 }
757 Err(e) => {
758 Log::new(Some(status_tx)).error(format!(
759 "OpenAPI client {} request failed for '{}': {}",
760 client_id, operation_id, e
761 ));
762 Err(e)
763 }
764 }
765 }
766
767 async fn run_follow_ups(
775 client_id: ClientId,
776 actions: Vec<serde_json::Value>,
777 app_state: &AppState,
778 status_tx: &mpsc::UnboundedSender<String>,
779 ) {
780 use crate::llm::actions::client_trait::{Client, ClientActionResult};
781 let protocol = crate::client::openapi::actions::OpenApiClientProtocol::new();
782 for action in actions {
783 let Ok(ClientActionResult::Custom { name, data }) =
784 protocol.execute_action(action.clone())
785 else {
786 continue;
787 };
788 if name != "openapi_operation" {
789 info!(
790 "OpenAPI client {} follow-up '{}' has no non-notifying path; skipped",
791 client_id, name
792 );
793 continue;
794 }
795 let to_map = |v: &serde_json::Value| -> HashMap<String, String> {
796 v.as_object()
797 .map(|o| {
798 o.iter()
799 .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
800 .collect()
801 })
802 .unwrap_or_default()
803 };
804 let result = Self::perform_operation(
805 client_id,
806 data["operation_id"]
807 .as_str()
808 .unwrap_or_default()
809 .to_string(),
810 to_map(&data["path_params"]),
811 to_map(&data["query_params"]),
812 data["headers"].as_object().cloned(),
813 data.get("body").cloned().unwrap_or(serde_json::Value::Null),
814 app_state,
815 status_tx,
816 )
817 .await;
818 if let Err(e) = result {
819 error!(
820 "OpenAPI client {} follow-up action failed: {}",
821 client_id, e
822 );
823 }
824 }
825 }
826
827 async fn notify_operation_response(
828 client_id: ClientId,
829 exchange: OpenApiExchange,
830 app_state: Arc<AppState>,
831 llm_client: OllamaClient,
832 status_tx: mpsc::UnboundedSender<String>,
833 ) {
834 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
835 return;
836 };
837
838 let protocol = Arc::new(crate::client::openapi::actions::OpenApiClientProtocol::new());
839 let event = Event::new(
840 &OPENAPI_OPERATION_RESPONSE_EVENT,
841 serde_json::json!({
842 "operation_id": exchange.operation_id,
843 "method": exchange.method,
844 "path": exchange.path,
845 "status_code": exchange.status_code,
846 "status_text": exchange.status_text,
847 "headers": exchange.headers,
848 "body": exchange.body,
849 }),
850 );
851
852 let memory = app_state
853 .get_memory_for_client(client_id)
854 .await
855 .unwrap_or_default();
856
857 match call_llm_for_client(
858 &llm_client,
859 &app_state,
860 client_id.to_string(),
861 &instruction,
862 &memory,
863 Some(&event),
864 protocol.as_ref(),
865 &status_tx,
866 )
867 .await
868 {
869 Ok(ClientLlmResult {
870 actions,
871 memory_updates,
872 }) => {
873 if let Some(mem) = memory_updates {
875 app_state.set_memory_for_client(client_id, mem).await;
876 }
877 Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
878 }
882 Err(e) => {
883 error!("LLM error for OpenAPI client {}: {}", client_id, e);
884 }
885 }
886 }
887
888 fn find_operation(spec: &OpenAPI, operation_id: &str) -> Result<(String, String)> {
890 for (path, path_item) in &spec.paths {
891 for (method, operation) in &path_item.operations {
892 let op_id = operation
893 .operation_id
894 .as_ref()
895 .map(|s| s.as_str())
896 .unwrap_or("");
897
898 if op_id == operation_id {
899 return Ok((path.clone(), method.clone()));
900 }
901 }
902 }
903
904 Err(anyhow!(
905 "Operation '{}' not found in OpenAPI spec",
906 operation_id
907 ))
908 }
909
910 fn substitute_path_params(template: &str, params: &HashMap<String, String>) -> Result<String> {
912 let mut path = template.to_string();
913
914 for (key, value) in params {
916 let pattern = format!("{{{}}}", key);
917 path = path.replace(&pattern, value);
918 }
919
920 if path.contains('{') {
922 return Err(anyhow!(
923 "Missing required path parameters in '{}': {}",
924 template,
925 path
926 ));
927 }
928
929 Ok(path)
930 }
931}