1pub 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
26pub struct OpenAiClient;
28
29impl OpenAiClient {
30 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 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 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 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 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 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 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 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 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 Ok("0.0.0.0:0".parse().unwrap())
181 }
182
183 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 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 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 ClientActionResult::Multiple(_) => Ok(Applied::Executed(
418 "multiple results are not produced by the OpenAI client".to_string(),
419 )),
420 }
421 }
422
423 #[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 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 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 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 app_state
528 .register_client_task(client_id, notify_handle)
529 .await;
530 outcome
531 }
532
533 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 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 #[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 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 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 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 request.max_tokens(tokens);
753 }
754
755 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 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 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 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 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 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 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 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 let request = CreateEmbeddingRequestArgs::default()
927 .model(&model_to_use)
928 .input(input_value)
929 .build()
930 .context("Failed to build embedding request")?;
931
932 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
981const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
988
989fn 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
1033async 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 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
1077fn 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#[derive(Clone, Copy)]
1088enum Dispatch {
1089 Spawn,
1093 Await,
1098}
1099
1100pub struct OpenAiExchange {
1106 pub event_data: serde_json::Value,
1108 pub summary: String,
1110}
1111
1112enum Applied {
1116 Executed(String),
1118 Disconnect,
1120}