1use crate::{
2 assistant_settings::{AssistantDockPosition, AssistantSettings},
3 OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role,
4};
5use anyhow::{anyhow, Result};
6use chrono::{DateTime, Local};
7use collections::HashMap;
8use editor::{Editor, ExcerptId, ExcerptRange, MultiBuffer};
9use fs::Fs;
10use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt};
11use gpui::{
12 actions, elements::*, executor::Background, Action, AppContext, AsyncAppContext, Entity,
13 ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
14 WindowContext,
15};
16use isahc::{http::StatusCode, Request, RequestExt};
17use language::{language_settings::SoftWrap, Buffer, LanguageRegistry};
18use settings::SettingsStore;
19use std::{cell::Cell, io, rc::Rc, sync::Arc};
20use util::{post_inc, ResultExt, TryFutureExt};
21use workspace::{
22 dock::{DockPosition, Panel},
23 item::Item,
24 pane, Pane, Workspace,
25};
26
27const OPENAI_API_URL: &'static str = "https://api.openai.com/v1";
28
29actions!(
30 assistant,
31 [NewContext, Assist, QuoteSelection, ToggleFocus, ResetKey]
32);
33
34pub fn init(cx: &mut AppContext) {
35 settings::register::<AssistantSettings>(cx);
36 cx.add_action(
37 |workspace: &mut Workspace, _: &NewContext, cx: &mut ViewContext<Workspace>| {
38 if let Some(this) = workspace.panel::<AssistantPanel>(cx) {
39 this.update(cx, |this, cx| this.add_context(cx))
40 }
41
42 workspace.focus_panel::<AssistantPanel>(cx);
43 },
44 );
45 cx.add_action(AssistantEditor::assist);
46 cx.capture_action(AssistantEditor::cancel_last_assist);
47 cx.add_action(AssistantEditor::quote_selection);
48 cx.add_action(AssistantPanel::save_api_key);
49 cx.add_action(AssistantPanel::reset_api_key);
50}
51
52pub enum AssistantPanelEvent {
53 ZoomIn,
54 ZoomOut,
55 Focus,
56 Close,
57 DockPositionChanged,
58}
59
60pub struct AssistantPanel {
61 width: Option<f32>,
62 height: Option<f32>,
63 pane: ViewHandle<Pane>,
64 api_key: Rc<Cell<Option<String>>>,
65 api_key_editor: Option<ViewHandle<Editor>>,
66 has_read_credentials: bool,
67 languages: Arc<LanguageRegistry>,
68 fs: Arc<dyn Fs>,
69 _subscriptions: Vec<Subscription>,
70}
71
72impl AssistantPanel {
73 pub fn load(
74 workspace: WeakViewHandle<Workspace>,
75 cx: AsyncAppContext,
76 ) -> Task<Result<ViewHandle<Self>>> {
77 cx.spawn(|mut cx| async move {
78 // TODO: deserialize state.
79 workspace.update(&mut cx, |workspace, cx| {
80 cx.add_view::<Self, _>(|cx| {
81 let weak_self = cx.weak_handle();
82 let pane = cx.add_view(|cx| {
83 let mut pane = Pane::new(
84 workspace.weak_handle(),
85 workspace.project().clone(),
86 workspace.app_state().background_actions,
87 Default::default(),
88 cx,
89 );
90 pane.set_can_split(false, cx);
91 pane.set_can_navigate(false, cx);
92 pane.on_can_drop(move |_, _| false);
93 pane.set_render_tab_bar_buttons(cx, move |pane, cx| {
94 let weak_self = weak_self.clone();
95 Flex::row()
96 .with_child(Pane::render_tab_bar_button(
97 0,
98 "icons/plus_12.svg",
99 false,
100 Some(("New Context".into(), Some(Box::new(NewContext)))),
101 cx,
102 move |_, cx| {
103 let weak_self = weak_self.clone();
104 cx.window_context().defer(move |cx| {
105 if let Some(this) = weak_self.upgrade(cx) {
106 this.update(cx, |this, cx| this.add_context(cx));
107 }
108 })
109 },
110 None,
111 ))
112 .with_child(Pane::render_tab_bar_button(
113 1,
114 if pane.is_zoomed() {
115 "icons/minimize_8.svg"
116 } else {
117 "icons/maximize_8.svg"
118 },
119 pane.is_zoomed(),
120 Some((
121 "Toggle Zoom".into(),
122 Some(Box::new(workspace::ToggleZoom)),
123 )),
124 cx,
125 move |pane, cx| pane.toggle_zoom(&Default::default(), cx),
126 None,
127 ))
128 .into_any()
129 });
130 let buffer_search_bar = cx.add_view(search::BufferSearchBar::new);
131 pane.toolbar()
132 .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx));
133 pane
134 });
135
136 let mut this = Self {
137 pane,
138 api_key: Rc::new(Cell::new(None)),
139 api_key_editor: None,
140 has_read_credentials: false,
141 languages: workspace.app_state().languages.clone(),
142 fs: workspace.app_state().fs.clone(),
143 width: None,
144 height: None,
145 _subscriptions: Default::default(),
146 };
147
148 let mut old_dock_position = this.position(cx);
149 this._subscriptions = vec![
150 cx.observe(&this.pane, |_, _, cx| cx.notify()),
151 cx.subscribe(&this.pane, Self::handle_pane_event),
152 cx.observe_global::<SettingsStore, _>(move |this, cx| {
153 let new_dock_position = this.position(cx);
154 if new_dock_position != old_dock_position {
155 old_dock_position = new_dock_position;
156 cx.emit(AssistantPanelEvent::DockPositionChanged);
157 }
158 }),
159 ];
160
161 this
162 })
163 })
164 })
165 }
166
167 fn handle_pane_event(
168 &mut self,
169 _pane: ViewHandle<Pane>,
170 event: &pane::Event,
171 cx: &mut ViewContext<Self>,
172 ) {
173 match event {
174 pane::Event::ZoomIn => cx.emit(AssistantPanelEvent::ZoomIn),
175 pane::Event::ZoomOut => cx.emit(AssistantPanelEvent::ZoomOut),
176 pane::Event::Focus => cx.emit(AssistantPanelEvent::Focus),
177 pane::Event::Remove => cx.emit(AssistantPanelEvent::Close),
178 _ => {}
179 }
180 }
181
182 fn add_context(&mut self, cx: &mut ViewContext<Self>) {
183 let focus = self.has_focus(cx);
184 let editor = cx
185 .add_view(|cx| AssistantEditor::new(self.api_key.clone(), self.languages.clone(), cx));
186 self.pane.update(cx, |pane, cx| {
187 pane.add_item(Box::new(editor), true, focus, None, cx)
188 });
189 }
190
191 fn save_api_key(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
192 if let Some(api_key) = self
193 .api_key_editor
194 .as_ref()
195 .map(|editor| editor.read(cx).text(cx))
196 {
197 if !api_key.is_empty() {
198 cx.platform()
199 .write_credentials(OPENAI_API_URL, "Bearer", api_key.as_bytes())
200 .log_err();
201 self.api_key.set(Some(api_key));
202 self.api_key_editor.take();
203 cx.focus_self();
204 cx.notify();
205 }
206 }
207 }
208
209 fn reset_api_key(&mut self, _: &ResetKey, cx: &mut ViewContext<Self>) {
210 cx.platform().delete_credentials(OPENAI_API_URL).log_err();
211 self.api_key.take();
212 self.api_key_editor = Some(build_api_key_editor(cx));
213 cx.focus_self();
214 cx.notify();
215 }
216}
217
218fn build_api_key_editor(cx: &mut ViewContext<AssistantPanel>) -> ViewHandle<Editor> {
219 cx.add_view(|cx| {
220 let mut editor = Editor::single_line(
221 Some(Arc::new(|theme| theme.assistant.api_key_editor.clone())),
222 cx,
223 );
224 editor.set_placeholder_text("sk-000000000000000000000000000000000000000000000000", cx);
225 editor
226 })
227}
228
229impl Entity for AssistantPanel {
230 type Event = AssistantPanelEvent;
231}
232
233impl View for AssistantPanel {
234 fn ui_name() -> &'static str {
235 "AssistantPanel"
236 }
237
238 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
239 let style = &theme::current(cx).assistant;
240 if let Some(api_key_editor) = self.api_key_editor.as_ref() {
241 Flex::column()
242 .with_child(
243 Text::new(
244 "Paste your OpenAI API key and press Enter to use the assistant",
245 style.api_key_prompt.text.clone(),
246 )
247 .aligned(),
248 )
249 .with_child(
250 ChildView::new(api_key_editor, cx)
251 .contained()
252 .with_style(style.api_key_editor.container)
253 .aligned(),
254 )
255 .contained()
256 .with_style(style.api_key_prompt.container)
257 .aligned()
258 .into_any()
259 } else {
260 ChildView::new(&self.pane, cx).into_any()
261 }
262 }
263
264 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
265 if cx.is_self_focused() {
266 if let Some(api_key_editor) = self.api_key_editor.as_ref() {
267 cx.focus(api_key_editor);
268 } else {
269 cx.focus(&self.pane);
270 }
271 }
272 }
273}
274
275impl Panel for AssistantPanel {
276 fn position(&self, cx: &WindowContext) -> DockPosition {
277 match settings::get::<AssistantSettings>(cx).dock {
278 AssistantDockPosition::Left => DockPosition::Left,
279 AssistantDockPosition::Bottom => DockPosition::Bottom,
280 AssistantDockPosition::Right => DockPosition::Right,
281 }
282 }
283
284 fn position_is_valid(&self, _: DockPosition) -> bool {
285 true
286 }
287
288 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
289 settings::update_settings_file::<AssistantSettings>(self.fs.clone(), cx, move |settings| {
290 let dock = match position {
291 DockPosition::Left => AssistantDockPosition::Left,
292 DockPosition::Bottom => AssistantDockPosition::Bottom,
293 DockPosition::Right => AssistantDockPosition::Right,
294 };
295 settings.dock = Some(dock);
296 });
297 }
298
299 fn size(&self, cx: &WindowContext) -> f32 {
300 let settings = settings::get::<AssistantSettings>(cx);
301 match self.position(cx) {
302 DockPosition::Left | DockPosition::Right => {
303 self.width.unwrap_or_else(|| settings.default_width)
304 }
305 DockPosition::Bottom => self.height.unwrap_or_else(|| settings.default_height),
306 }
307 }
308
309 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
310 match self.position(cx) {
311 DockPosition::Left | DockPosition::Right => self.width = Some(size),
312 DockPosition::Bottom => self.height = Some(size),
313 }
314 cx.notify();
315 }
316
317 fn should_zoom_in_on_event(event: &AssistantPanelEvent) -> bool {
318 matches!(event, AssistantPanelEvent::ZoomIn)
319 }
320
321 fn should_zoom_out_on_event(event: &AssistantPanelEvent) -> bool {
322 matches!(event, AssistantPanelEvent::ZoomOut)
323 }
324
325 fn is_zoomed(&self, cx: &WindowContext) -> bool {
326 self.pane.read(cx).is_zoomed()
327 }
328
329 fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>) {
330 self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx));
331 }
332
333 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
334 if active {
335 if self.api_key.clone().take().is_none() && !self.has_read_credentials {
336 self.has_read_credentials = true;
337 let api_key = if let Some((_, api_key)) = cx
338 .platform()
339 .read_credentials(OPENAI_API_URL)
340 .log_err()
341 .flatten()
342 {
343 String::from_utf8(api_key).log_err()
344 } else {
345 None
346 };
347 if let Some(api_key) = api_key {
348 self.api_key.set(Some(api_key));
349 } else if self.api_key_editor.is_none() {
350 self.api_key_editor = Some(build_api_key_editor(cx));
351 cx.notify();
352 }
353 }
354
355 if self.pane.read(cx).items_len() == 0 {
356 self.add_context(cx);
357 }
358 }
359 }
360
361 fn icon_path(&self) -> &'static str {
362 "icons/speech_bubble_12.svg"
363 }
364
365 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
366 ("Assistant Panel".into(), Some(Box::new(ToggleFocus)))
367 }
368
369 fn should_change_position_on_event(event: &Self::Event) -> bool {
370 matches!(event, AssistantPanelEvent::DockPositionChanged)
371 }
372
373 fn should_activate_on_event(_: &Self::Event) -> bool {
374 false
375 }
376
377 fn should_close_on_event(event: &AssistantPanelEvent) -> bool {
378 matches!(event, AssistantPanelEvent::Close)
379 }
380
381 fn has_focus(&self, cx: &WindowContext) -> bool {
382 self.pane.read(cx).has_focus()
383 || self
384 .api_key_editor
385 .as_ref()
386 .map_or(false, |editor| editor.is_focused(cx))
387 }
388
389 fn is_focus_event(event: &Self::Event) -> bool {
390 matches!(event, AssistantPanelEvent::Focus)
391 }
392}
393
394struct Assistant {
395 buffer: ModelHandle<MultiBuffer>,
396 messages: Vec<Message>,
397 messages_by_id: HashMap<ExcerptId, Message>,
398 completion_count: usize,
399 pending_completions: Vec<PendingCompletion>,
400 languages: Arc<LanguageRegistry>,
401 api_key: Rc<Cell<Option<String>>>,
402}
403
404impl Entity for Assistant {
405 type Event = ();
406}
407
408impl Assistant {
409 fn new(
410 api_key: Rc<Cell<Option<String>>>,
411 language_registry: Arc<LanguageRegistry>,
412 cx: &mut ModelContext<Self>,
413 ) -> Self {
414 let mut this = Self {
415 buffer: cx.add_model(|_| MultiBuffer::new(0)),
416 messages: Default::default(),
417 messages_by_id: Default::default(),
418 completion_count: Default::default(),
419 pending_completions: Default::default(),
420 languages: language_registry,
421 api_key,
422 };
423 this.push_message(Role::User, cx);
424 this
425 }
426
427 fn assist(&mut self, cx: &mut ModelContext<Self>) {
428 let messages = self
429 .messages
430 .iter()
431 .map(|message| RequestMessage {
432 role: message.role,
433 content: message.content.read(cx).text(),
434 })
435 .collect();
436 let request = OpenAIRequest {
437 model: "gpt-3.5-turbo".into(),
438 messages,
439 stream: true,
440 };
441
442 if let Some(api_key) = self.api_key.clone().take() {
443 let stream = stream_completion(api_key, cx.background().clone(), request);
444 let response = self.push_message(Role::Assistant, cx);
445 self.push_message(Role::User, cx);
446 let task = cx.spawn(|this, mut cx| {
447 async move {
448 let mut messages = stream.await?;
449
450 while let Some(message) = messages.next().await {
451 let mut message = message?;
452 if let Some(choice) = message.choices.pop() {
453 response.content.update(&mut cx, |content, cx| {
454 let text: Arc<str> = choice.delta.content?.into();
455 content.edit([(content.len()..content.len(), text)], None, cx);
456 Some(())
457 });
458 }
459 }
460
461 this.update(&mut cx, |this, _| {
462 this.pending_completions
463 .retain(|completion| completion.id != this.completion_count);
464 });
465
466 anyhow::Ok(())
467 }
468 .log_err()
469 });
470
471 self.pending_completions.push(PendingCompletion {
472 id: post_inc(&mut self.completion_count),
473 _task: task,
474 });
475 }
476 }
477
478 fn cancel_last_assist(&mut self) -> bool {
479 self.pending_completions.pop().is_some()
480 }
481
482 fn push_message(&mut self, role: Role, cx: &mut ModelContext<Self>) -> Message {
483 let content = cx.add_model(|cx| {
484 let mut buffer = Buffer::new(0, "", cx);
485 let markdown = self.languages.language_for_name("Markdown");
486 cx.spawn_weak(|buffer, mut cx| async move {
487 let markdown = markdown.await?;
488 let buffer = buffer
489 .upgrade(&cx)
490 .ok_or_else(|| anyhow!("buffer was dropped"))?;
491 buffer.update(&mut cx, |buffer, cx| {
492 buffer.set_language(Some(markdown), cx)
493 });
494 anyhow::Ok(())
495 })
496 .detach_and_log_err(cx);
497 buffer.set_language_registry(self.languages.clone());
498 buffer
499 });
500 let excerpt_id = self.buffer.update(cx, |buffer, cx| {
501 buffer
502 .push_excerpts(
503 content.clone(),
504 vec![ExcerptRange {
505 context: 0..0,
506 primary: None,
507 }],
508 cx,
509 )
510 .pop()
511 .unwrap()
512 });
513
514 let message = Message {
515 role,
516 content: content.clone(),
517 sent_at: Local::now(),
518 };
519 self.messages.push(message.clone());
520 self.messages_by_id.insert(excerpt_id, message.clone());
521 message
522 }
523}
524
525struct PendingCompletion {
526 id: usize,
527 _task: Task<Option<()>>,
528}
529
530struct AssistantEditor {
531 assistant: ModelHandle<Assistant>,
532 editor: ViewHandle<Editor>,
533}
534
535impl AssistantEditor {
536 fn new(
537 api_key: Rc<Cell<Option<String>>>,
538 language_registry: Arc<LanguageRegistry>,
539 cx: &mut ViewContext<Self>,
540 ) -> Self {
541 let assistant = cx.add_model(|cx| Assistant::new(api_key, language_registry, cx));
542 let editor = cx.add_view(|cx| {
543 let mut editor = Editor::for_multibuffer(assistant.read(cx).buffer.clone(), None, cx);
544 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
545 editor.set_show_gutter(false, cx);
546 editor.set_render_excerpt_header(
547 {
548 let assistant = assistant.clone();
549 move |_editor, params: editor::RenderExcerptHeaderParams, cx| {
550 let style = &theme::current(cx).assistant;
551 if let Some(message) = assistant.read(cx).messages_by_id.get(¶ms.id) {
552 let sender = match message.role {
553 Role::User => Label::new("You", style.user_sender.text.clone())
554 .contained()
555 .with_style(style.user_sender.container),
556 Role::Assistant => {
557 Label::new("Assistant", style.assistant_sender.text.clone())
558 .contained()
559 .with_style(style.assistant_sender.container)
560 }
561 Role::System => {
562 Label::new("System", style.assistant_sender.text.clone())
563 .contained()
564 .with_style(style.assistant_sender.container)
565 }
566 };
567
568 Flex::row()
569 .with_child(sender.aligned())
570 .with_child(
571 Label::new(
572 message.sent_at.format("%I:%M%P").to_string(),
573 style.sent_at.text.clone(),
574 )
575 .contained()
576 .with_style(style.sent_at.container)
577 .aligned(),
578 )
579 .aligned()
580 .left()
581 .contained()
582 .with_style(style.header)
583 .into_any()
584 } else {
585 Empty::new().into_any()
586 }
587 }
588 },
589 cx,
590 );
591 editor
592 });
593 Self { assistant, editor }
594 }
595
596 fn assist(&mut self, _: &Assist, cx: &mut ViewContext<Self>) {
597 self.assistant
598 .update(cx, |assistant, cx| assistant.assist(cx));
599 }
600
601 fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext<Self>) {
602 if !self
603 .assistant
604 .update(cx, |assistant, _| assistant.cancel_last_assist())
605 {
606 cx.propagate_action();
607 }
608 }
609
610 fn quote_selection(
611 workspace: &mut Workspace,
612 _: &QuoteSelection,
613 cx: &mut ViewContext<Workspace>,
614 ) {
615 let Some(panel) = workspace.panel::<AssistantPanel>(cx) else {
616 return;
617 };
618 let Some(editor) = workspace.active_item(cx).and_then(|item| item.downcast::<Editor>()) else {
619 return;
620 };
621
622 let text = editor.read_with(cx, |editor, cx| {
623 let range = editor.selections.newest::<usize>(cx).range();
624 let buffer = editor.buffer().read(cx).snapshot(cx);
625 let start_language = buffer.language_at(range.start);
626 let end_language = buffer.language_at(range.end);
627 let language_name = if start_language == end_language {
628 start_language.map(|language| language.name())
629 } else {
630 None
631 };
632 let language_name = language_name.as_deref().unwrap_or("").to_lowercase();
633
634 let selected_text = buffer.text_for_range(range).collect::<String>();
635 if selected_text.is_empty() {
636 None
637 } else {
638 Some(if language_name == "markdown" {
639 selected_text
640 .lines()
641 .map(|line| format!("> {}", line))
642 .collect::<Vec<_>>()
643 .join("\n")
644 } else {
645 format!("```{language_name}\n{selected_text}\n```")
646 })
647 }
648 });
649
650 // Activate the panel
651 if !panel.read(cx).has_focus(cx) {
652 workspace.toggle_panel_focus::<AssistantPanel>(cx);
653 }
654
655 if let Some(text) = text {
656 panel.update(cx, |panel, cx| {
657 if let Some(assistant) = panel
658 .pane
659 .read(cx)
660 .active_item()
661 .and_then(|item| item.downcast::<AssistantEditor>())
662 .ok_or_else(|| anyhow!("no active context"))
663 .log_err()
664 {
665 assistant.update(cx, |assistant, cx| {
666 assistant
667 .editor
668 .update(cx, |editor, cx| editor.insert(&text, cx))
669 });
670 }
671 });
672 }
673 }
674}
675
676impl Entity for AssistantEditor {
677 type Event = ();
678}
679
680impl View for AssistantEditor {
681 fn ui_name() -> &'static str {
682 "ContextEditor"
683 }
684
685 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
686 let theme = &theme::current(cx).assistant;
687
688 ChildView::new(&self.editor, cx)
689 .contained()
690 .with_style(theme.container)
691 .into_any()
692 }
693
694 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
695 if cx.is_self_focused() {
696 cx.focus(&self.editor);
697 }
698 }
699}
700
701impl Item for AssistantEditor {
702 fn tab_content<V: View>(
703 &self,
704 _: Option<usize>,
705 style: &theme::Tab,
706 _: &gpui::AppContext,
707 ) -> AnyElement<V> {
708 Label::new("New Context", style.label.clone()).into_any()
709 }
710}
711
712#[derive(Clone)]
713struct Message {
714 role: Role,
715 content: ModelHandle<Buffer>,
716 sent_at: DateTime<Local>,
717}
718
719async fn stream_completion(
720 api_key: String,
721 executor: Arc<Background>,
722 mut request: OpenAIRequest,
723) -> Result<impl Stream<Item = Result<OpenAIResponseStreamEvent>>> {
724 request.stream = true;
725
726 let (tx, rx) = futures::channel::mpsc::unbounded::<Result<OpenAIResponseStreamEvent>>();
727
728 let json_data = serde_json::to_string(&request)?;
729 let mut response = Request::post(format!("{OPENAI_API_URL}/chat/completions"))
730 .header("Content-Type", "application/json")
731 .header("Authorization", format!("Bearer {}", api_key))
732 .body(json_data)?
733 .send_async()
734 .await?;
735
736 let status = response.status();
737 if status == StatusCode::OK {
738 executor
739 .spawn(async move {
740 let mut lines = BufReader::new(response.body_mut()).lines();
741
742 fn parse_line(
743 line: Result<String, io::Error>,
744 ) -> Result<Option<OpenAIResponseStreamEvent>> {
745 if let Some(data) = line?.strip_prefix("data: ") {
746 let event = serde_json::from_str(&data)?;
747 Ok(Some(event))
748 } else {
749 Ok(None)
750 }
751 }
752
753 while let Some(line) = lines.next().await {
754 if let Some(event) = parse_line(line).transpose() {
755 tx.unbounded_send(event).log_err();
756 }
757 }
758
759 anyhow::Ok(())
760 })
761 .detach();
762
763 Ok(rx)
764 } else {
765 let mut body = String::new();
766 response.body_mut().read_to_string(&mut body).await?;
767
768 Err(anyhow!(
769 "Failed to connect to OpenAI API: {} {}",
770 response.status(),
771 body,
772 ))
773 }
774}