Skip to main content

netget/client/openai/
mod.rs

1//! OpenAI client implementation
2pub mod actions;
3
4pub use actions::OpenAiClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info, warn};
11
12use crate::client::llm_budget::call_llm_for_client;
13use crate::client::openai::actions::{
14    OPENAI_CLIENT_CONNECTED_EVENT, OPENAI_CLIENT_RESPONSE_RECEIVED_EVENT,
15};
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::actions::protocol_trait::Protocol;
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::logging::emit::Log;
21use crate::protocol::{Event, StartupParams};
22use crate::state::app_state::AppState;
23use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
24use crate::state::{AccessLogOwner, ClientId, ClientStatus};
25
26/// OpenAI client that connects to the OpenAI API
27pub struct OpenAiClient;
28
29impl OpenAiClient {
30    /// Connect to OpenAI API with integrated LLM actions
31    pub async fn connect_with_llm_actions(
32        remote_addr: String,
33        llm_client: OllamaClient,
34        app_state: Arc<AppState>,
35        status_tx: mpsc::UnboundedSender<String>,
36        client_id: ClientId,
37        startup_params: Option<StartupParams>,
38    ) -> Result<SocketAddr> {
39        // Extract API key from startup params
40        let api_key = startup_params
41            .as_ref()
42            .map(|p| p.get_string("api_key"))
43            .transpose()?
44            .context("OpenAI API key is required")?;
45
46        let default_model = startup_params
47            .as_ref()
48            .map(|p| p.get_optional_string("default_model"))
49            .transpose()?
50            .flatten()
51            .unwrap_or_else(|| "gpt-3.5-turbo".to_string());
52
53        let organization = startup_params
54            .as_ref()
55            .map(|p| p.get_optional_string("organization"))
56            .transpose()?
57            .flatten();
58
59        // The endpoint comes from `remote_addr` and from nowhere else, and it is resolved
60        // here so there is exactly one place to read. See `api_base_for`.
61        let api_base = api_base_for(&remote_addr)?;
62
63        info!(
64            "OpenAI client {} initializing with API base: {}",
65            client_id, api_base
66        );
67
68        // Store configuration in protocol_data
69        app_state
70            .with_client_mut(client_id, |client| {
71                client.set_protocol_field("api_key".to_string(), serde_json::json!(api_key));
72                client.set_protocol_field(
73                    "default_model".to_string(),
74                    serde_json::json!(default_model),
75                );
76                client.set_protocol_field("api_endpoint".to_string(), serde_json::json!(api_base));
77                if let Some(org) = organization {
78                    client.set_protocol_field("organization".to_string(), serde_json::json!(org));
79                }
80            })
81            .await;
82
83        // Update status
84        app_state
85            .update_client_status(client_id, ClientStatus::Connected)
86            .await;
87        Log::new(Some(&status_tx)).info(format!(
88            "OpenAI client {} ready (endpoint: {})",
89            client_id, api_base
90        ));
91        let _ = status_tx.send("__UPDATE_UI__".to_string());
92
93        // Command channel for injected actions (the dashboard's [ send ] row).
94        // Registered - and already being drained by its own task - BEFORE the
95        // connected-event LLM call, which a manual `*` rule can park for minutes: the
96        // operator must be able to reach the client while it waits. This task also
97        // replaces the old 5s "is the client gone yet" poll: the channel closes when the
98        // client is removed, which ends the loop immediately.
99        let command_rx =
100            crate::client::command_support::register_command_channel(&app_state, client_id).await;
101        let cmd_task = tokio::spawn(Self::command_loop(
102            command_rx,
103            client_id,
104            app_state.clone(),
105            llm_client.clone(),
106            status_tx.clone(),
107        ));
108        app_state.register_client_task(client_id, cmd_task).await;
109
110        // Call LLM with openai_connected event
111        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
112            let event = Event::new(
113                &OPENAI_CLIENT_CONNECTED_EVENT,
114                serde_json::json!({
115                    "api_endpoint": api_base.clone(),
116                }),
117            );
118
119            match call_llm_for_client(
120                &llm_client,
121                &app_state,
122                client_id.to_string(),
123                &instruction,
124                &String::new(),
125                Some(&event),
126                &crate::client::openai::actions::OpenAiClientProtocol,
127                &status_tx,
128            )
129            .await
130            {
131                Ok(result) => {
132                    info!("OpenAI client ready after connect event");
133                    let protocol = crate::client::openai::actions::OpenAiClientProtocol::new();
134                    for action in result.actions {
135                        match protocol.execute_action(action.clone()) {
136                            Ok(ClientActionResult::Disconnect) => {
137                                info!(
138                                    "OpenAI client {} disconnecting on connect-event action",
139                                    client_id
140                                );
141                                app_state
142                                    .update_client_status(client_id, ClientStatus::Disconnected)
143                                    .await;
144                                // Every exit path drops the handle so the dashboard stops
145                                // offering [ send ] into a dead client.
146                                app_state.remove_client_handle(client_id).await;
147                                let _ = status_tx.send("__UPDATE_UI__".to_string());
148                                return Ok("0.0.0.0:0".parse().unwrap());
149                            }
150                            Ok(result) => {
151                                // Dispatch::Spawn: a 30s API call must not hold up
152                                // `connect()`. The injected path awaits the same
153                                // `apply_action` so its ClientSendOutcome is truthful.
154                                if let Err(e) = Self::apply_action(
155                                    result,
156                                    Dispatch::Spawn,
157                                    client_id,
158                                    &app_state,
159                                    &llm_client,
160                                    &status_tx,
161                                )
162                                .await
163                                {
164                                    error!("OpenAI request failed: {}", e);
165                                }
166                            }
167                            Err(e) => {
168                                error!("OpenAI action execution error: {}", e);
169                            }
170                        }
171                    }
172                }
173                Err(e) => {
174                    error!("LLM error on openai_connected event: {}", e);
175                }
176            }
177        }
178
179        // Return a dummy local address (OpenAI is a remote API, not a local connection)
180        Ok("0.0.0.0:0".parse().unwrap())
181    }
182
183    /// Drain injected commands until the channel closes (client removed) or an injected
184    /// `disconnect` ends the session.
185    ///
186    /// `command_support::handle_stream_client_command` cannot serve this client: it writes
187    /// `SendData` to a socket, and every OpenAI verb yields `ClientActionResult::Custom`
188    /// that has to go out through `async-openai`. So the action is executed through the
189    /// protocol's own `execute_action` and applied by [`Self::apply_action`] - the same
190    /// function the connected-event path uses - and the outcome is recorded and replied
191    /// exactly the way the generic arm does it.
192    async fn command_loop(
193        mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
194        client_id: ClientId,
195        app_state: Arc<AppState>,
196        llm_client: OllamaClient,
197        status_tx: mpsc::UnboundedSender<String>,
198    ) {
199        let protocol = crate::client::openai::actions::OpenAiClientProtocol::new();
200
201        while let Some(command) = command_rx.recv().await {
202            let action = command.action.clone();
203            let outcome = match protocol.execute_action(action.clone()) {
204                Err(e) => Ok(ClientSendOutcome::Rejected {
205                    error: e.to_string(),
206                }),
207                // Dispatch::Await: the network exchange is awaited, so the reported
208                // outcome describes a request that has actually completed. The
209                // openai_response_received event is delivered from its own registered
210                // task, so a manual handler parked for a human's think time cannot wedge
211                // this loop or time out the dashboard's [ send ].
212                Ok(result) => Self::apply_action(
213                    result,
214                    Dispatch::Await,
215                    client_id,
216                    &app_state,
217                    &llm_client,
218                    &status_tx,
219                )
220                .await
221                .map(|applied| match applied {
222                    Applied::Disconnect => ClientSendOutcome::Disconnected,
223                    Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
224                }),
225            };
226
227            let outcome_json = match &outcome {
228                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
229                Err(e) => serde_json::json!({"error": e.to_string()}),
230            };
231            app_state
232                .record_access_log(
233                    AccessLogOwner::Client(client_id.as_u32()),
234                    protocol.protocol_name(),
235                    None,
236                    "injected_action",
237                    action,
238                    vec![outcome_json],
239                )
240                .await;
241
242            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
243            if let Err(e) = &outcome {
244                error!("OpenAI client {} injected action failed: {}", client_id, e);
245                let _ = status_tx.send(format!(
246                    "[WARN] Client {} injected action failed: {}",
247                    client_id, e
248                ));
249            }
250            let _ = status_tx.send("__UPDATE_UI__".to_string());
251            crate::client::command_support::reply(command, outcome);
252
253            if disconnect {
254                app_state
255                    .update_client_status(client_id, ClientStatus::Disconnected)
256                    .await;
257                break;
258            }
259        }
260
261        info!("OpenAI client {} command loop stopped", client_id);
262        app_state.remove_client_handle(client_id).await;
263        let _ = status_tx.send("__UPDATE_UI__".to_string());
264    }
265
266    /// Apply one already-executed action result. The single place an OpenAI API call is
267    /// dispatched from, so an injected action behaves exactly like an LLM-produced one.
268    async fn apply_action(
269        result: ClientActionResult,
270        dispatch: Dispatch,
271        client_id: ClientId,
272        app_state: &Arc<AppState>,
273        llm_client: &OllamaClient,
274        status_tx: &mpsc::UnboundedSender<String>,
275    ) -> Result<Applied> {
276        match result {
277            ClientActionResult::Custom { name, data } if name == "openai_chat_completion" => {
278                let messages = data
279                    .get("messages")
280                    .cloned()
281                    .unwrap_or(serde_json::Value::Null);
282                let model = data
283                    .get("model")
284                    .and_then(|v| v.as_str())
285                    .map(|s| s.to_string());
286                let temperature = data.get("temperature").and_then(|v| v.as_f64());
287                let max_tokens = data
288                    .get("max_tokens")
289                    .and_then(|v| v.as_u64())
290                    .and_then(|n| u32::try_from(n).ok());
291                let functions = data.get("functions").cloned().filter(|v| !v.is_null());
292                let model_label = model.clone().unwrap_or_else(|| "<default>".to_string());
293
294                match dispatch {
295                    Dispatch::Spawn => {
296                        let state_clone = app_state.clone();
297                        let llm_clone = llm_client.clone();
298                        let status_clone = status_tx.clone();
299                        let request_handle = tokio::spawn(async move {
300                            if let Err(e) = Self::make_chat_completion(
301                                client_id,
302                                messages,
303                                model,
304                                temperature,
305                                max_tokens,
306                                functions,
307                                state_clone,
308                                llm_clone,
309                                status_clone,
310                            )
311                            .await
312                            {
313                                error!("OpenAI chat completion failed: {}", e);
314                            }
315                        });
316                        app_state
317                            .register_client_task(client_id, request_handle)
318                            .await;
319                        Ok(Applied::Executed(format!(
320                            "send_chat_completion dispatched (model={model_label})"
321                        )))
322                    }
323                    Dispatch::Await => {
324                        let exchange = Self::finish_exchange(
325                            Self::perform_chat_completion(
326                                client_id,
327                                messages,
328                                model,
329                                temperature,
330                                max_tokens,
331                                functions,
332                                app_state,
333                                status_tx,
334                            )
335                            .await,
336                            client_id,
337                            app_state,
338                            llm_client,
339                            status_tx,
340                        )
341                        .await?;
342                        Ok(Applied::Executed(format!(
343                            "send_chat_completion completed (model={model_label}, {})",
344                            exchange.summary
345                        )))
346                    }
347                }
348            }
349            ClientActionResult::Custom { name, data } if name == "openai_embedding" => {
350                let input = data
351                    .get("input")
352                    .cloned()
353                    .unwrap_or(serde_json::Value::Null);
354                let model = data
355                    .get("model")
356                    .and_then(|v| v.as_str())
357                    .map(|s| s.to_string());
358                let model_label = model
359                    .clone()
360                    .unwrap_or_else(|| "text-embedding-ada-002".to_string());
361
362                match dispatch {
363                    Dispatch::Spawn => {
364                        let state_clone = app_state.clone();
365                        let llm_clone = llm_client.clone();
366                        let status_clone = status_tx.clone();
367                        let request_handle = tokio::spawn(async move {
368                            if let Err(e) = Self::make_embedding_request(
369                                client_id,
370                                input,
371                                model,
372                                state_clone,
373                                llm_clone,
374                                status_clone,
375                            )
376                            .await
377                            {
378                                error!("OpenAI embedding request failed: {}", e);
379                            }
380                        });
381                        app_state
382                            .register_client_task(client_id, request_handle)
383                            .await;
384                        Ok(Applied::Executed(format!(
385                            "send_embedding_request dispatched (model={model_label})"
386                        )))
387                    }
388                    Dispatch::Await => {
389                        let exchange = Self::finish_exchange(
390                            Self::perform_embedding_request(
391                                client_id, input, model, app_state, status_tx,
392                            )
393                            .await,
394                            client_id,
395                            app_state,
396                            llm_client,
397                            status_tx,
398                        )
399                        .await?;
400                        Ok(Applied::Executed(format!(
401                            "send_embedding_request completed (model={model_label}, {})",
402                            exchange.summary
403                        )))
404                    }
405                }
406            }
407            ClientActionResult::Custom { name, .. } => Ok(Applied::Executed(format!(
408                "custom result '{name}' has no OpenAI executor"
409            ))),
410            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
411            ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
412            ClientActionResult::NoAction => Ok(Applied::Executed("no_action".to_string())),
413            ClientActionResult::SendData(_) => Ok(Applied::Executed(
414                "raw send_data has no meaning for the OpenAI HTTPS client".to_string(),
415            )),
416            // OpenAiClientProtocol::execute_action never produces Multiple.
417            ClientActionResult::Multiple(_) => Ok(Applied::Executed(
418                "multiple results are not produced by the OpenAI client".to_string(),
419            )),
420        }
421    }
422
423    /// Make a chat completion request and hand the result to the LLM.
424    #[allow(clippy::too_many_arguments)]
425    pub async fn make_chat_completion(
426        client_id: ClientId,
427        messages: serde_json::Value,
428        model: Option<String>,
429        temperature: Option<f64>,
430        max_tokens: Option<u32>,
431        functions: Option<serde_json::Value>,
432        app_state: Arc<AppState>,
433        llm_client: OllamaClient,
434        status_tx: mpsc::UnboundedSender<String>,
435    ) -> Result<()> {
436        let outcome = Self::perform_chat_completion(
437            client_id,
438            messages,
439            model,
440            temperature,
441            max_tokens,
442            functions,
443            &app_state,
444            &status_tx,
445        )
446        .await;
447        Self::notify_outcome(outcome, client_id, &app_state, &llm_client, &status_tx)
448            .await
449            .map(|_| ())
450    }
451
452    /// Make an embedding request and hand the result to the LLM.
453    pub async fn make_embedding_request(
454        client_id: ClientId,
455        input: serde_json::Value,
456        model: Option<String>,
457        app_state: Arc<AppState>,
458        llm_client: OllamaClient,
459        status_tx: mpsc::UnboundedSender<String>,
460    ) -> Result<()> {
461        let outcome =
462            Self::perform_embedding_request(client_id, input, model, &app_state, &status_tx).await;
463        Self::notify_outcome(outcome, client_id, &app_state, &llm_client, &status_tx)
464            .await
465            .map(|_| ())
466    }
467
468    /// Deliver an exchange's `openai_response_received` event **inline** and wait for it.
469    /// Used by the LLM-driven path, where nobody is waiting on a reply.
470    async fn notify_outcome(
471        outcome: Result<OpenAiExchange>,
472        client_id: ClientId,
473        app_state: &Arc<AppState>,
474        llm_client: &OllamaClient,
475        status_tx: &mpsc::UnboundedSender<String>,
476    ) -> Result<OpenAiExchange> {
477        let event_data = match &outcome {
478            Ok(exchange) => exchange.event_data.clone(),
479            Err(e) => error_event_data(e),
480        };
481        Self::notify_response(
482            client_id,
483            event_data,
484            app_state.clone(),
485            llm_client.clone(),
486            status_tx.clone(),
487        )
488        .await;
489        outcome
490    }
491
492    /// Deliver an exchange's `openai_response_received` event from **its own registered
493    /// task** and return the exchange immediately.
494    ///
495    /// This is the point of the perform/notify split. The injected-command loop already
496    /// holds the truthful network result and must reply to the operator before that
497    /// event's handler runs: a dashboard-created client defaults to a `*` -> manual rule,
498    /// so the handler can park for a human's think time (300s by default), far longer
499    /// than the composer's 30s send timeout. What the model decides to do with the
500    /// response is a different question from whether the injected action reached the wire.
501    async fn finish_exchange(
502        outcome: Result<OpenAiExchange>,
503        client_id: ClientId,
504        app_state: &Arc<AppState>,
505        llm_client: &OllamaClient,
506        status_tx: &mpsc::UnboundedSender<String>,
507    ) -> Result<OpenAiExchange> {
508        let event_data = match &outcome {
509            Ok(exchange) => exchange.event_data.clone(),
510            Err(e) => error_event_data(e),
511        };
512        let state_clone = app_state.clone();
513        let llm_clone = llm_client.clone();
514        let status_clone = status_tx.clone();
515        let notify_handle = tokio::spawn(async move {
516            OpenAiClient::notify_response(
517                client_id,
518                event_data,
519                state_clone,
520                llm_clone,
521                status_clone,
522            )
523            .await;
524        });
525        // Registered so the notification - and the LLM call it makes - is aborted when
526        // the client is stopped.
527        app_state
528            .register_client_task(client_id, notify_handle)
529            .await;
530        outcome
531    }
532
533    /// Fire one `openai_response_received` event at the LLM and apply any memory update.
534    async fn notify_response(
535        client_id: ClientId,
536        event_data: serde_json::Value,
537        app_state: Arc<AppState>,
538        llm_client: OllamaClient,
539        status_tx: mpsc::UnboundedSender<String>,
540    ) {
541        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
542            return;
543        };
544        let protocol = Arc::new(crate::client::openai::actions::OpenAiClientProtocol::new());
545        let event = Event::new(&OPENAI_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
546        let memory = app_state
547            .get_memory_for_client(client_id)
548            .await
549            .unwrap_or_default();
550
551        match call_llm_for_client(
552            &llm_client,
553            &app_state,
554            client_id.to_string(),
555            &instruction,
556            &memory,
557            Some(&event),
558            protocol.as_ref(),
559            &status_tx,
560        )
561        .await
562        {
563            Ok(ClientLlmResult {
564                actions,
565                memory_updates,
566            }) => {
567                if let Some(mem) = memory_updates {
568                    app_state.set_memory_for_client(client_id, mem).await;
569                }
570
571                // Execute what the model asked for. These used to be discarded
572                // (`actions: _`), so answering openai_response_received did nothing --
573                // the whole point of raising it. A model that reads a completion and
574                // wants to send a follow-up prompt was silently ignored.
575                //
576                // They run through the `perform_*` round-trips, which raise no event and
577                // call no LLM. That bounds the loop -- a follow-up cannot raise another
578                // response event and drive the model round in circles -- and it is also
579                // the only shape that compiles: routing them back through
580                // `make_chat_completion` would make notify -> apply -> make -> notify a
581                // self-referential async chain, which rustc cannot prove `Send`.
582                use crate::llm::actions::client_trait::{Client, ClientActionResult};
583                for action in actions {
584                    let decoded = match protocol.execute_action(action.clone()) {
585                        Ok(d) => d,
586                        Err(e) => {
587                            error!(
588                                "OpenAI client {} rejected its own follow-up action: {}",
589                                client_id, e
590                            );
591                            continue;
592                        }
593                    };
594                    let ClientActionResult::Custom { name, data } = decoded else {
595                        continue;
596                    };
597                    let outcome = match name.as_str() {
598                        "openai_chat_completion" => Self::perform_chat_completion(
599                            client_id,
600                            data.get("messages")
601                                .cloned()
602                                .unwrap_or(serde_json::Value::Null),
603                            data.get("model").and_then(|v| v.as_str()).map(String::from),
604                            data.get("temperature").and_then(|v| v.as_f64()),
605                            data.get("max_tokens")
606                                .and_then(|v| v.as_u64())
607                                .and_then(|n| u32::try_from(n).ok()),
608                            data.get("functions").cloned().filter(|v| !v.is_null()),
609                            &app_state,
610                            &status_tx,
611                        )
612                        .await
613                        .map(|_| ()),
614                        "openai_embedding" => Self::perform_embedding_request(
615                            client_id,
616                            data.get("input")
617                                .cloned()
618                                .unwrap_or(serde_json::Value::Null),
619                            data.get("model").and_then(|v| v.as_str()).map(String::from),
620                            &app_state,
621                            &status_tx,
622                        )
623                        .await
624                        .map(|_| ()),
625                        other => {
626                            info!(
627                                "OpenAI client {} follow-up '{}' has no wire effect",
628                                client_id, other
629                            );
630                            Ok(())
631                        }
632                    };
633                    if let Err(e) = outcome {
634                        error!("OpenAI client {} follow-up action failed: {}", client_id, e);
635                    }
636                }
637            }
638            Err(e) => {
639                error!("LLM error for OpenAI client {}: {}", client_id, e);
640            }
641        }
642    }
643
644    /// Perform the chat-completion round-trip only. No LLM involvement, so a caller can
645    /// await this and know exactly what OpenAI answered.
646    #[allow(clippy::too_many_arguments)]
647    pub async fn perform_chat_completion(
648        client_id: ClientId,
649        messages: serde_json::Value,
650        model: Option<String>,
651        temperature: Option<f64>,
652        max_tokens: Option<u32>,
653        functions: Option<serde_json::Value>,
654        app_state: &Arc<AppState>,
655        status_tx: &mpsc::UnboundedSender<String>,
656    ) -> Result<OpenAiExchange> {
657        // Get API configuration from client
658        let (api_key, default_model, api_endpoint) = app_state
659            .with_client_mut(client_id, |client| {
660                let key = client
661                    .get_protocol_field("api_key")
662                    .and_then(|v| v.as_str())
663                    .map(|s| s.to_string());
664                let model = client
665                    .get_protocol_field("default_model")
666                    .and_then(|v| v.as_str())
667                    .map(|s| s.to_string());
668                let endpoint = client
669                    .get_protocol_field("api_endpoint")
670                    .and_then(|v| v.as_str())
671                    .map(|s| s.to_string());
672                (key, model, endpoint)
673            })
674            .await
675            .unwrap_or((None, None, None));
676
677        let api_key = api_key.context("No API key found")?;
678        let model_to_use =
679            model.unwrap_or_else(|| default_model.unwrap_or_else(|| "gpt-3.5-turbo".to_string()));
680
681        info!(
682            "OpenAI client {} making chat completion request with model: {}",
683            client_id, model_to_use
684        );
685
686        use async_openai::types::*;
687
688        let api_endpoint = api_endpoint.context(
689            "No API endpoint recorded for this client; refusing rather than falling back to \
690             api.openai.com",
691        )?;
692        let openai_client = api_client(&api_endpoint, &api_key).await?;
693
694        // Parse messages array
695        let messages_array = messages.as_array().context("Messages must be an array")?;
696
697        let mut chat_messages = Vec::new();
698        for msg in messages_array {
699            let role = msg
700                .get("role")
701                .and_then(|v| v.as_str())
702                .context("Message role is required")?;
703            let content = msg
704                .get("content")
705                .and_then(|v| v.as_str())
706                .context("Message content is required")?;
707
708            let chat_message = match role {
709                "system" => {
710                    ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
711                        content: ChatCompletionRequestSystemMessageContent::Text(
712                            content.to_string(),
713                        ),
714                        name: None,
715                    })
716                }
717                "user" => ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
718                    content: ChatCompletionRequestUserMessageContent::Text(content.to_string()),
719                    name: None,
720                }),
721                "assistant" => {
722                    ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage {
723                        content: Some(ChatCompletionRequestAssistantMessageContent::Text(
724                            content.to_string(),
725                        )),
726                        name: None,
727                        tool_calls: None,
728                        refusal: None,
729                        #[allow(deprecated)]
730                        function_call: None,
731                    })
732                }
733                _ => return Err(anyhow::anyhow!("Unknown message role: {}", role)),
734            };
735            chat_messages.push(chat_message);
736        }
737
738        // Build request
739        let mut request = CreateChatCompletionRequestArgs::default();
740        request.model(&model_to_use);
741        request.messages(chat_messages);
742
743        if let Some(temp) = temperature {
744            request.temperature(temp as f32);
745        }
746
747        if let Some(tokens) = max_tokens {
748            // `tokens as u16` was the second of two narrowing casts on this value:
749            // `max_tokens: 70000` reached the wire as 4464, which reads as a deliberate
750            // budget rather than as the request the model actually made. The field is a
751            // `u32`; the value is range-checked in `execute_action` before it gets here.
752            request.max_tokens(tokens);
753        }
754
755        // Add function calling support
756        if let Some(functions_value) = functions {
757            if let Some(functions_array) = functions_value.as_array() {
758                let mut tools = Vec::new();
759                for func in functions_array {
760                    // Convert the function definition to OpenAI tool format
761                    if let Ok(tool) = serde_json::from_value::<
762                        async_openai::types::ChatCompletionTool,
763                    >(func.clone())
764                    {
765                        tools.push(tool);
766                    } else {
767                        warn!(
768                            "OpenAI client {}: Failed to parse function definition: {:?}",
769                            client_id, func
770                        );
771                    }
772                }
773                if !tools.is_empty() {
774                    let tools_count = tools.len();
775                    request.tools(tools);
776                    info!(
777                        "OpenAI client {}: Added {} function(s) to request",
778                        client_id, tools_count
779                    );
780                }
781            }
782        }
783
784        let request = request
785            .build()
786            .context("Failed to build chat completion request")?;
787
788        // Make request
789        match openai_client.chat().create(request).await {
790            Ok(response) => {
791                let choice = response
792                    .choices
793                    .first()
794                    .context("No choices in OpenAI response")?;
795
796                let content = choice
797                    .message
798                    .content
799                    .as_ref()
800                    .map(|s| s.to_string())
801                    .unwrap_or_default();
802
803                let total_tokens = response.usage.as_ref().map(|u| u.total_tokens).unwrap_or(0);
804                let usage = serde_json::json!({
805                    "prompt_tokens": response.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0),
806                    "completion_tokens": response.usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0),
807                    "total_tokens": total_tokens,
808                });
809
810                // Extract tool_calls if present
811                let tool_calls = choice.message.tool_calls.as_ref().map(|calls| {
812                    calls
813                        .iter()
814                        .map(|call| {
815                            serde_json::json!({
816                                "id": call.id,
817                                "type": "function",
818                                "function": {
819                                    "name": call.function.name,
820                                    "arguments": call.function.arguments
821                                }
822                            })
823                        })
824                        .collect::<Vec<_>>()
825                });
826
827                info!(
828                    "OpenAI client {} received response ({} tokens{})",
829                    client_id,
830                    total_tokens,
831                    if tool_calls.is_some() {
832                        ", with tool calls"
833                    } else {
834                        ""
835                    }
836                );
837
838                let summary = format!(
839                    "{} tokens, {} chars of content",
840                    total_tokens,
841                    content.chars().count()
842                );
843
844                // Built here, delivered by `notify_response` - inline for the LLM path,
845                // from its own task for the injected-command path.
846                let mut event_data = serde_json::json!({
847                    "response_type": "chat_completion",
848                    "content": content,
849                    "model": response.model,
850                    "usage": usage,
851                });
852                if let Some(calls) = tool_calls {
853                    event_data["tool_calls"] = serde_json::json!(calls);
854                }
855
856                Ok(OpenAiExchange {
857                    event_data,
858                    summary,
859                })
860            }
861            Err(e) => {
862                Log::new(Some(status_tx))
863                    .error(format!("OpenAI client {} request failed: {}", client_id, e));
864                Err(e.into())
865            }
866        }
867    }
868
869    /// Perform the embedding round-trip only. No LLM involvement.
870    pub async fn perform_embedding_request(
871        client_id: ClientId,
872        input: serde_json::Value,
873        model: Option<String>,
874        app_state: &Arc<AppState>,
875        status_tx: &mpsc::UnboundedSender<String>,
876    ) -> Result<OpenAiExchange> {
877        // Get API configuration from client
878        let (api_key, api_endpoint) = app_state
879            .with_client_mut(client_id, |client| {
880                let key = client
881                    .get_protocol_field("api_key")
882                    .and_then(|v| v.as_str())
883                    .map(|s| s.to_string());
884                let endpoint = client
885                    .get_protocol_field("api_endpoint")
886                    .and_then(|v| v.as_str())
887                    .map(|s| s.to_string());
888                (key, endpoint)
889            })
890            .await
891            .unwrap_or((None, None));
892
893        let api_key = api_key.context("No API key found")?;
894        let model_to_use = model.unwrap_or_else(|| "text-embedding-ada-002".to_string());
895
896        info!(
897            "OpenAI client {} making embedding request with model: {}",
898            client_id, model_to_use
899        );
900
901        use async_openai::types::*;
902
903        let api_endpoint = api_endpoint.context(
904            "No API endpoint recorded for this client; refusing rather than falling back to \
905             api.openai.com",
906        )?;
907        let openai_client = api_client(&api_endpoint, &api_key).await?;
908
909        // Parse input (can be string or array)
910        let input_value = if let Some(text) = input.as_str() {
911            EmbeddingInput::String(text.to_string())
912        } else if let Some(arr) = input.as_array() {
913            let strings: Vec<String> = arr
914                .iter()
915                .filter_map(|v| v.as_str())
916                .map(|s| s.to_string())
917                .collect();
918            EmbeddingInput::StringArray(strings)
919        } else {
920            return Err(anyhow::anyhow!(
921                "Input must be a string or array of strings"
922            ));
923        };
924
925        // Build request
926        let request = CreateEmbeddingRequestArgs::default()
927            .model(&model_to_use)
928            .input(input_value)
929            .build()
930            .context("Failed to build embedding request")?;
931
932        // Make request
933        match openai_client.embeddings().create(request).await {
934            Ok(response) => {
935                let embeddings: Vec<Vec<f32>> =
936                    response.data.iter().map(|e| e.embedding.clone()).collect();
937
938                let usage = serde_json::json!({
939                    "prompt_tokens": response.usage.prompt_tokens,
940                    "total_tokens": response.usage.total_tokens,
941                });
942
943                info!(
944                    "OpenAI client {} received {} embeddings ({} tokens)",
945                    client_id,
946                    embeddings.len(),
947                    response.usage.total_tokens
948                );
949
950                let summary = format!(
951                    "{} embeddings of {} dimensions",
952                    embeddings.len(),
953                    embeddings.first().map(|e| e.len()).unwrap_or(0)
954                );
955
956                let event_data = serde_json::json!({
957                    "response_type": "embedding",
958                    "content": format!("Generated {} embeddings", embeddings.len()),
959                    "model": response.model,
960                    "usage": usage,
961                    "embeddings_count": embeddings.len(),
962                    "embedding_dimensions": embeddings.first().map(|e| e.len()).unwrap_or(0),
963                });
964
965                Ok(OpenAiExchange {
966                    event_data,
967                    summary,
968                })
969            }
970            Err(e) => {
971                Log::new(Some(status_tx)).error(format!(
972                    "OpenAI client {} embedding request failed: {}",
973                    client_id, e
974                ));
975                Err(e.into())
976            }
977        }
978    }
979}
980
981/// How long one OpenAI API call may take, end to end.
982///
983/// `async-openai` builds a `reqwest::Client::new()` when it is not given one, and that has no
984/// timeout at all. The injected-command loop awaits each request in turn, so an endpoint that
985/// accepts the connection and never answers used to wedge the dashboard's `[ send ]` for this
986/// client for the life of the process.
987const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
988
989/// Turn the address the operator named into an API base URL, or refuse.
990///
991/// This is the only place the endpoint is decided. It used to be decided twice and by
992/// omission: the config was left at `async-openai`'s default unless `remote_addr` was
993/// non-empty *and* not literally `https://api.openai.com/v1`, so an empty or unrecorded
994/// address meant the vendor default — `https://api.openai.com/v1` — signed with whatever key
995/// the client was given. That is the DynamoDB shape from `CLAUDE.md`: a client that loses its
996/// target and reaches the real service instead of failing. Talking to real OpenAI is a
997/// legitimate thing to ask this client for; arriving there because nobody said otherwise is
998/// not.
999///
1000/// A scheme is left as given. Without one, `https://` is assumed except for a loopback host,
1001/// where `http://` is the only thing that could be meant — the same normalisation the Ollama
1002/// client does for the same reason, that `reqwest` needs an absolute URL and a bare
1003/// `host:port` fails every request with "relative URL without a base".
1004///
1005/// The base must include the version path (`/v1`); `async-openai` appends only the operation
1006/// (`/chat/completions`) to it.
1007fn api_base_for(remote_addr: &str) -> Result<String> {
1008    let addr = remote_addr.trim();
1009    if addr.is_empty() {
1010        return Err(anyhow::anyhow!(
1011            "OpenAI client needs an endpoint in remote_addr (for example \
1012             https://api.openai.com/v1 or http://127.0.0.1:8080/v1); refusing to fall back to \
1013             api.openai.com"
1014        ));
1015    }
1016    if addr.contains("://") {
1017        return Ok(addr.to_string());
1018    }
1019
1020    let host = crate::llm::ollama_client::host_of(addr);
1021    let loopback = host == "localhost"
1022        || host
1023            .parse::<std::net::IpAddr>()
1024            .map(|ip| ip.is_loopback())
1025            .unwrap_or(false);
1026    Ok(format!(
1027        "{}{}",
1028        if loopback { "http://" } else { "https://" },
1029        addr
1030    ))
1031}
1032
1033/// An `async-openai` client for `api_base`, over a `reqwest` client built once per endpoint.
1034///
1035/// `Client::with_config` makes its own `reqwest::Client::new()`, so every request used to pay
1036/// for building the rustls stack and loading the platform root store — on macOS a synchronous
1037/// keychain read through Security.framework, on the async runtime, parking a tokio worker.
1038/// It also meant no timeout and no resolver override, so a client pointed at a literal IP
1039/// performed a real `getaddrinfo` per request (measured at 8.25 s under concurrency).
1040///
1041/// Keyed by endpoint because the resolver override is per-host. The `Mutex` holds nothing
1042/// across an await.
1043async fn api_client(
1044    api_base: &str,
1045    api_key: &str,
1046) -> Result<async_openai::Client<async_openai::config::OpenAIConfig>> {
1047    use std::collections::HashMap;
1048    use std::sync::{Mutex, OnceLock};
1049
1050    static CLIENTS: OnceLock<Mutex<HashMap<String, reqwest::Client>>> = OnceLock::new();
1051    let cache = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
1052
1053    let http = match cache.lock().ok().and_then(|map| map.get(api_base).cloned()) {
1054        Some(client) => client,
1055        None => {
1056            let owned = api_base.to_string();
1057            let built = tokio::task::spawn_blocking(move || {
1058                crate::llm::ollama_client::client_for_endpoint_with_timeout(&owned, REQUEST_TIMEOUT)
1059            })
1060            .await
1061            .context("OpenAI HTTP client build task panicked")?;
1062            match cache.lock() {
1063                Ok(mut map) => map.entry(api_base.to_string()).or_insert(built).clone(),
1064                Err(_) => built,
1065            }
1066        }
1067    };
1068
1069    // The key is set on the config and nowhere else: it must not reach a log line, an event
1070    // or the status stream. `async-openai` puts it in the Authorization header itself.
1071    let config = async_openai::config::OpenAIConfig::new()
1072        .with_api_key(api_key)
1073        .with_api_base(api_base);
1074    Ok(async_openai::Client::with_config(config).with_http_client(http))
1075}
1076
1077/// The `openai_response_received` payload for a request that failed. Kept next to the
1078/// success payload so both paths report through the same event type.
1079fn error_event_data(error: &anyhow::Error) -> serde_json::Value {
1080    serde_json::json!({
1081        "response_type": "error",
1082        "content": error.to_string(),
1083    })
1084}
1085
1086/// How an OpenAI API call is issued.
1087#[derive(Clone, Copy)]
1088enum Dispatch {
1089    /// Spawn the request and return immediately. Used by the connected-event handler,
1090    /// which runs inline in `connect()` and must not block client creation on a request
1091    /// that can take the full 30s timeout.
1092    Spawn,
1093    /// Await the API round-trip so the caller can report what actually happened. Used by
1094    /// the injected-command loop. The response event is still delivered to the LLM, from
1095    /// its own registered task, so a parked manual handler cannot wedge the command loop
1096    /// for the length of a human's think time.
1097    Await,
1098}
1099
1100/// One completed OpenAI exchange.
1101///
1102/// Split out of [`OpenAiClient::make_chat_completion`] so the injected-command loop can
1103/// await the network round-trip - and report a truthful outcome - without also awaiting
1104/// the LLM call the response event triggers.
1105pub struct OpenAiExchange {
1106    /// The `openai_response_received` payload this exchange produced.
1107    pub event_data: serde_json::Value,
1108    /// A short human summary for an injected action's outcome detail.
1109    pub summary: String,
1110}
1111
1112/// What [`OpenAiClient::apply_action`] did with one action. The OpenAI client owns no
1113/// socket, so there is no honest byte count to report - only "the API call ran" or
1114/// "the session should end".
1115enum Applied {
1116    /// The action ran; the string says what, for the injected action's outcome detail.
1117    Executed(String),
1118    /// The session should end.
1119    Disconnect,
1120}