Skip to main content

netget/client/openapi/
mod.rs

1//! OpenAPI client implementation - spec-driven HTTP requests
2pub 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
29/// One completed OpenAPI operation.
30///
31/// Split out of [`OpenApiClient::execute_operation`] so the injected-command loop can await
32/// the HTTP round-trip - and report a truthful outcome - without also awaiting the LLM call
33/// the `openapi_operation_response` event triggers.
34pub 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
44/// What one executed action did.
45enum Applied {
46    /// The action ran; `detail` says what it did.
47    Executed(String),
48    /// The action asked to end the session.
49    Disconnect,
50}
51
52/// How an OpenAPI operation is issued.
53#[derive(Clone, Copy)]
54enum Dispatch {
55    /// Spawn the whole operation and return immediately. Used by the connected-event
56    /// handler, which runs inline in `connect()` and must not block client creation on a
57    /// request that can take the full 30s timeout.
58    Spawn,
59    /// Await the HTTP exchange so the caller can report what actually happened, then raise
60    /// the response event from its own registered task. Used by the injected-command loop,
61    /// so a parked manual handler cannot wedge it for the length of a human's think time.
62    Await,
63}
64
65/// How much of a response body is read before it is refused.
66///
67/// The body is remote-controlled and ends up in an event and then in an LLM prompt.
68const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
69
70/// Read a response body as text, refusing anything past [`MAX_RESPONSE_BYTES`].
71///
72/// Refuses as soon as the cap is passed rather than after buffering, so a server answering
73/// with an endless stream costs at most the cap.
74async 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
88/// OpenAPI client that makes spec-driven requests to HTTP servers
89pub struct OpenApiClient;
90
91impl OpenApiClient {
92    /// The process-wide HTTP client, built once.
93    ///
94    /// Same reasoning as the HTTP and DoH clients: `Client::builder().build()` sets up the
95    /// rustls stack and loads the platform root store, which on macOS reads the system
96    /// keychain synchronously and serialises across processes. It runs on `spawn_blocking`
97    /// so it cannot park a tokio worker, and it is kept so operations after the first
98    /// reuse the connection pool rather than rebuilding a TLS stack each time.
99    #[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    /// The HTTP client: the process-wide reqwest client natively ([`Self::reqwest_client`]),
118    /// the shared hyper transport over the virtual loopback in the browser build
119    /// ([`crate::client::http_fetch`]), with the same 30-second bound.
120    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    /// Connect to an OpenAPI server with integrated LLM actions
129    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        // Parse startup parameters to get spec
143        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            // Load spec from file
148            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        // Parse OpenAPI spec
157        let parsed_spec: OpenAPI =
158            serde_yaml::from_str(&spec_yaml).context("Failed to parse OpenAPI spec (YAML)")?;
159
160        // Determine the base URL. Precedence: an explicit `base_url` parameter, then the
161        // address the operator named, and the spec's own `servers[0]` only as a last resort.
162        //
163        // **That order used to be inverted**, and the inversion is the defect `CLAUDE.md`
164        // records for the DynamoDB client wearing different clothes: the spec's declared
165        // server won over `remote_addr`, so an operator who said "connect to 127.0.0.1:9000"
166        // and handed over a spec whose `servers:` block names
167        // `https://api.production.example.com` got the production host — silently, with no
168        // log line saying so. A spec is data supplied with the request, often by the model;
169        // it must not be able to retarget the client away from the address it was given.
170        //
171        // The tell was in the test suite: `tests/client/openapi/e2e_test.rs` has to rewrite
172        // `{port}` inside the spec before every run, which is the workaround you write when
173        // `remote_addr` is not what decides. It still passes now, because both agree.
174        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                    // reqwest needs an absolute URL; a bare `host:port` fails every request
182                    // with "relative URL without a base".
183                    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                // Nothing was named, so the spec is all there is. Say so, rather than
197                // arriving at a host nobody in this conversation typed.
198                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        // Warm the shared client rather than building one and dropping it. This used to
214        // bind to `_http_client`: a full rustls stack built at connect time and discarded,
215        // while every operation built another one.
216        Self::http_client().await?;
217        // The browser transport has no TLS: an `https://` base URL is refused here, with the
218        // reason, rather than on the first operation.
219        crate::client::http_fetch::check_url(&base_url)?;
220
221        // Store spec and base URL in protocol_data
222        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        // Update status
234        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        // Command channel for injected actions (the dashboard's [ send ] row).
244        // Registered and *served* before the openapi_client_connected LLM call below:
245        // a dashboard-created client defaults to a `*` -> manual routing rule, so that
246        // call can park for minutes and [ send ] has to work for the whole park.
247        let command_rx =
248            crate::client::command_support::register_command_channel(&app_state, client_id).await;
249
250        // The command loop replaces the old 5s "is the client gone yet?" poll: when the
251        // client is removed its handle is dropped, the channel closes and `recv()`
252        // returns None, so the loop notices removal immediately instead of up to 5s later.
253        // Registered with AppState so stop_client can abort it —
254        // dropping a JoinHandle only detaches it in Tokio.
255        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        // Extract operation list for LLM
267        let operations = Self::extract_operations(&parsed_spec);
268
269        // Call LLM with openapi_client_connected event
270        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(), // No memory yet
291                Some(&event),
292                &crate::client::openapi::actions::OpenApiClientProtocol,
293                &status_tx,
294            )
295            .await
296            {
297                Ok(result) => {
298                    // Execute actions from LLM response
299                    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        // Return dummy address (HTTP is connectionless)
315        Ok("0.0.0.0:0".parse().unwrap())
316    }
317
318    /// Extract operation list from OpenAPI spec for LLM
319    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            // Iterate over HTTP methods in path_item.operations
324            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    /// Execute actions returned by LLM.
345    ///
346    /// Each action goes through the same [`Self::apply_action`] the dashboard's
347    /// injected-command path uses; the only difference is that this path spawns it, so
348    /// `connect()` can return without waiting for the request to complete.
349    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                    // The command loop tears the client's handle down when the client
363                    // itself is removed; nothing to close here (OpenAPI owns no socket).
364                }
365                Ok(applied) => {
366                    // Dispatch::Spawn so connect() is not blocked on the request.
367                    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    /// Apply one already-parsed action against the live OpenAPI client.
394    ///
395    /// The single place OpenAPI actions become HTTP traffic, so an action injected from the
396    /// dashboard behaves exactly like one the model produced. Under [`Dispatch::Await`]
397    /// only the **network** half is awaited; the response event - and the LLM call it makes
398    /// - is raised from its own registered task afterwards, so a `*` -> manual routing rule
399    /// parking that event cannot wedge the command loop for the length of a human's think
400    /// time. The outcome stays truthful because it carries the status the server actually
401    /// returned, which is known before the event is raised.
402    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                        // Registered with AppState so stop_client can abort this task —
443                        // dropping a JoinHandle only detaches it in Tokio.
444                        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    /// Serve injected commands until the client goes away.
523    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                // Never `Sent`: reqwest owns the socket and does not report how many bytes
540                // the request serialised to, so a byte count here would be invented.
541                // `Executed` carries the response status instead.
542                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    /// Execute an OpenAPI operation and hand the response to the LLM.
598    #[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    /// Resolve the operation against the spec and perform the HTTP round-trip only.
627    ///
628    /// No LLM involvement, so a caller can await this and know exactly what the server
629    /// answered. An `operation_id` the spec does not define fails here, which is why an
630    /// injected command for a missing operation reports an error rather than success.
631    #[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        // Get spec and base URL from client
643        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        // Parse spec
662        let parsed_spec: OpenAPI =
663            serde_yaml::from_str(&spec_yaml).context("Failed to parse OpenAPI spec")?;
664
665        // Find operation in spec
666        let (path_template, method) = Self::find_operation(&parsed_spec, &operation_id)?;
667
668        // Substitute path parameters
669        let path = Self::substitute_path_params(&path_template, &path_params)?;
670
671        // Build full URL
672        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        // Build request
682        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        // Add query parameters
693        if !query_params.is_empty() {
694            request = request.query(&query_params);
695        }
696
697        // Add headers
698        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        // Add body if not null
707        if !body.is_null() {
708            request = request.json(&body);
709        }
710
711        // Execute request
712        match request.send().await {
713            Ok(response) => {
714                let status = response.status();
715                let status_code = status.as_u16();
716
717                // Get headers
718                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                // Get body, bounded. `response.text()` buffers the whole thing with no
726                // limit, and the body is remote-controlled: it goes into an
727                // `openapi_operation_response` event and from there into an LLM prompt,
728                // where a megabyte is already useless. `unwrap_or_default()` also turned a
729                // read failure into an empty body, which reads downstream as "the server
730                // answered with nothing" rather than "we could not read the answer".
731                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    /// Hand a completed exchange to the LLM as an `openapi_operation_response` event.
768    /// Run the actions the model returned for a response event.
769    ///
770    /// They were discarded (`actions: _`), so answering openapi_operation_response did
771    /// nothing -- the whole point of raising it. Dispatch goes through
772    /// `perform_operation`, which raises no event: that bounds the loop and avoids a
773    /// notify -> perform -> notify chain rustc cannot prove `Send`.
774    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                // Update memory
874                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                // Note: We don't execute follow-up actions here to avoid recursive async
879                // function issues. The LLM can handle follow-up operations by including
880                // them in the original response, or the operator can inject them.
881            }
882            Err(e) => {
883                error!("LLM error for OpenAPI client {}: {}", client_id, e);
884            }
885        }
886    }
887
888    /// Find operation in spec by operation_id
889    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    /// Substitute path parameters in template
911    fn substitute_path_params(template: &str, params: &HashMap<String, String>) -> Result<String> {
912        let mut path = template.to_string();
913
914        // Replace {param} with values
915        for (key, value) in params {
916            let pattern = format!("{{{}}}", key);
917            path = path.replace(&pattern, value);
918        }
919
920        // Check for unsubstituted parameters
921        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}