1use anyhow::{Result, anyhow};
2use collections::{BTreeMap, HashMap};
3use credentials_provider::CredentialsProvider;
4use futures::Stream;
5use futures::{FutureExt, StreamExt, future::BoxFuture};
6use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
7use http_client::HttpClient;
8use language_model::{
9 ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
10 LanguageModelCompletionEvent, LanguageModelId, LanguageModelImage, LanguageModelName,
11 LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
12 LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestMessage,
13 LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse,
14 LanguageModelToolUseId, MessageContent, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME,
15 RateLimiter, Role, StopReason, TokenUsage, env_var,
16};
17use menu;
18use open_ai::responses::{
19 ResponseFunctionCallItem, ResponseFunctionCallOutputContent, ResponseFunctionCallOutputItem,
20 ResponseInputContent, ResponseInputItem, ResponseMessageItem,
21};
22use open_ai::{
23 ImageUrl, Model, OPEN_AI_API_URL, ReasoningEffort, ResponseStreamEvent,
24 responses::{
25 Request as ResponseRequest, ResponseOutputItem, ResponseSummary as ResponsesSummary,
26 ResponseUsage as ResponsesUsage, StreamEvent as ResponsesStreamEvent, stream_response,
27 },
28 stream_completion,
29};
30use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore};
31use std::pin::Pin;
32use std::sync::{Arc, LazyLock};
33use strum::IntoEnumIterator;
34use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
35use ui_input::InputField;
36use util::ResultExt;
37
38use crate::provider::util::{fix_streamed_json, parse_tool_arguments};
39
40const PROVIDER_ID: LanguageModelProviderId = OPEN_AI_PROVIDER_ID;
41const PROVIDER_NAME: LanguageModelProviderName = OPEN_AI_PROVIDER_NAME;
42
43const API_KEY_ENV_VAR_NAME: &str = "OPENAI_API_KEY";
44static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
45
46#[derive(Default, Clone, Debug, PartialEq)]
47pub struct OpenAiSettings {
48 pub api_url: String,
49 pub available_models: Vec<AvailableModel>,
50}
51
52pub struct OpenAiLanguageModelProvider {
53 http_client: Arc<dyn HttpClient>,
54 state: Entity<State>,
55}
56
57pub struct State {
58 api_key_state: ApiKeyState,
59 credentials_provider: Arc<dyn CredentialsProvider>,
60}
61
62impl State {
63 fn is_authenticated(&self) -> bool {
64 self.api_key_state.has_key()
65 }
66
67 fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
68 let credentials_provider = self.credentials_provider.clone();
69 let api_url = OpenAiLanguageModelProvider::api_url(cx);
70 self.api_key_state.store(
71 api_url,
72 api_key,
73 |this| &mut this.api_key_state,
74 credentials_provider,
75 cx,
76 )
77 }
78
79 fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
80 let credentials_provider = self.credentials_provider.clone();
81 let api_url = OpenAiLanguageModelProvider::api_url(cx);
82 self.api_key_state.load_if_needed(
83 api_url,
84 |this| &mut this.api_key_state,
85 credentials_provider,
86 cx,
87 )
88 }
89}
90
91impl OpenAiLanguageModelProvider {
92 pub fn new(
93 http_client: Arc<dyn HttpClient>,
94 credentials_provider: Arc<dyn CredentialsProvider>,
95 cx: &mut App,
96 ) -> Self {
97 let state = cx.new(|cx| {
98 cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
99 let credentials_provider = this.credentials_provider.clone();
100 let api_url = Self::api_url(cx);
101 this.api_key_state.handle_url_change(
102 api_url,
103 |this| &mut this.api_key_state,
104 credentials_provider,
105 cx,
106 );
107 cx.notify();
108 })
109 .detach();
110 State {
111 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
112 credentials_provider,
113 }
114 });
115
116 Self { http_client, state }
117 }
118
119 fn create_language_model(&self, model: open_ai::Model) -> Arc<dyn LanguageModel> {
120 Arc::new(OpenAiLanguageModel {
121 id: LanguageModelId::from(model.id().to_string()),
122 model,
123 state: self.state.clone(),
124 http_client: self.http_client.clone(),
125 request_limiter: RateLimiter::new(4),
126 })
127 }
128
129 fn settings(cx: &App) -> &OpenAiSettings {
130 &crate::AllLanguageModelSettings::get_global(cx).openai
131 }
132
133 fn api_url(cx: &App) -> SharedString {
134 let api_url = &Self::settings(cx).api_url;
135 if api_url.is_empty() {
136 open_ai::OPEN_AI_API_URL.into()
137 } else {
138 SharedString::new(api_url.as_str())
139 }
140 }
141}
142
143impl LanguageModelProviderState for OpenAiLanguageModelProvider {
144 type ObservableEntity = State;
145
146 fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
147 Some(self.state.clone())
148 }
149}
150
151impl LanguageModelProvider for OpenAiLanguageModelProvider {
152 fn id(&self) -> LanguageModelProviderId {
153 PROVIDER_ID
154 }
155
156 fn name(&self) -> LanguageModelProviderName {
157 PROVIDER_NAME
158 }
159
160 fn icon(&self) -> IconOrSvg {
161 IconOrSvg::Icon(IconName::AiOpenAi)
162 }
163
164 fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
165 Some(self.create_language_model(open_ai::Model::default()))
166 }
167
168 fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
169 Some(self.create_language_model(open_ai::Model::default_fast()))
170 }
171
172 fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
173 let mut models = BTreeMap::default();
174
175 // Add base models from open_ai::Model::iter()
176 for model in open_ai::Model::iter() {
177 if !matches!(model, open_ai::Model::Custom { .. }) {
178 models.insert(model.id().to_string(), model);
179 }
180 }
181
182 // Override with available models from settings
183 for model in &OpenAiLanguageModelProvider::settings(cx).available_models {
184 models.insert(
185 model.name.clone(),
186 open_ai::Model::Custom {
187 name: model.name.clone(),
188 display_name: model.display_name.clone(),
189 max_tokens: model.max_tokens,
190 max_output_tokens: model.max_output_tokens,
191 max_completion_tokens: model.max_completion_tokens,
192 reasoning_effort: model.reasoning_effort.clone(),
193 supports_chat_completions: model.capabilities.chat_completions,
194 },
195 );
196 }
197
198 models
199 .into_values()
200 .map(|model| self.create_language_model(model))
201 .collect()
202 }
203
204 fn is_authenticated(&self, cx: &App) -> bool {
205 self.state.read(cx).is_authenticated()
206 }
207
208 fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
209 self.state.update(cx, |state, cx| state.authenticate(cx))
210 }
211
212 fn configuration_view(
213 &self,
214 _target_agent: language_model::ConfigurationViewTargetAgent,
215 window: &mut Window,
216 cx: &mut App,
217 ) -> AnyView {
218 cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
219 .into()
220 }
221
222 fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
223 self.state
224 .update(cx, |state, cx| state.set_api_key(None, cx))
225 }
226}
227
228pub struct OpenAiLanguageModel {
229 id: LanguageModelId,
230 model: open_ai::Model,
231 state: Entity<State>,
232 http_client: Arc<dyn HttpClient>,
233 request_limiter: RateLimiter,
234}
235
236impl OpenAiLanguageModel {
237 fn stream_completion(
238 &self,
239 request: open_ai::Request,
240 cx: &AsyncApp,
241 ) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>>>
242 {
243 let http_client = self.http_client.clone();
244
245 let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
246 let api_url = OpenAiLanguageModelProvider::api_url(cx);
247 (state.api_key_state.key(&api_url), api_url)
248 });
249
250 let future = self.request_limiter.stream(async move {
251 let provider = PROVIDER_NAME;
252 let Some(api_key) = api_key else {
253 return Err(LanguageModelCompletionError::NoApiKey { provider });
254 };
255 let request = stream_completion(
256 http_client.as_ref(),
257 provider.0.as_str(),
258 &api_url,
259 &api_key,
260 request,
261 );
262 let response = request.await?;
263 Ok(response)
264 });
265
266 async move { Ok(future.await?.boxed()) }.boxed()
267 }
268
269 fn stream_response(
270 &self,
271 request: ResponseRequest,
272 cx: &AsyncApp,
273 ) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponsesStreamEvent>>>>
274 {
275 let http_client = self.http_client.clone();
276
277 let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
278 let api_url = OpenAiLanguageModelProvider::api_url(cx);
279 (state.api_key_state.key(&api_url), api_url)
280 });
281
282 let provider = PROVIDER_NAME;
283 let future = self.request_limiter.stream(async move {
284 let Some(api_key) = api_key else {
285 return Err(LanguageModelCompletionError::NoApiKey { provider });
286 };
287 let request = stream_response(
288 http_client.as_ref(),
289 provider.0.as_str(),
290 &api_url,
291 &api_key,
292 request,
293 );
294 let response = request.await?;
295 Ok(response)
296 });
297
298 async move { Ok(future.await?.boxed()) }.boxed()
299 }
300}
301
302impl LanguageModel for OpenAiLanguageModel {
303 fn id(&self) -> LanguageModelId {
304 self.id.clone()
305 }
306
307 fn name(&self) -> LanguageModelName {
308 LanguageModelName::from(self.model.display_name().to_string())
309 }
310
311 fn provider_id(&self) -> LanguageModelProviderId {
312 PROVIDER_ID
313 }
314
315 fn provider_name(&self) -> LanguageModelProviderName {
316 PROVIDER_NAME
317 }
318
319 fn supports_tools(&self) -> bool {
320 true
321 }
322
323 fn supports_images(&self) -> bool {
324 use open_ai::Model;
325 match &self.model {
326 Model::FourOmniMini
327 | Model::FourPointOneNano
328 | Model::Five
329 | Model::FiveCodex
330 | Model::FiveMini
331 | Model::FiveNano
332 | Model::FivePointOne
333 | Model::FivePointTwo
334 | Model::FivePointTwoCodex
335 | Model::FivePointThreeCodex
336 | Model::FivePointFour
337 | Model::FivePointFourPro
338 | Model::O1
339 | Model::O3 => true,
340 Model::ThreePointFiveTurbo
341 | Model::Four
342 | Model::FourTurbo
343 | Model::O3Mini
344 | Model::Custom { .. } => false,
345 }
346 }
347
348 fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
349 match choice {
350 LanguageModelToolChoice::Auto => true,
351 LanguageModelToolChoice::Any => true,
352 LanguageModelToolChoice::None => true,
353 }
354 }
355
356 fn supports_streaming_tools(&self) -> bool {
357 true
358 }
359
360 fn supports_thinking(&self) -> bool {
361 self.model.reasoning_effort().is_some()
362 }
363
364 fn supports_split_token_display(&self) -> bool {
365 true
366 }
367
368 fn telemetry_id(&self) -> String {
369 format!("openai/{}", self.model.id())
370 }
371
372 fn max_token_count(&self) -> u64 {
373 self.model.max_token_count()
374 }
375
376 fn max_output_tokens(&self) -> Option<u64> {
377 self.model.max_output_tokens()
378 }
379
380 fn count_tokens(
381 &self,
382 request: LanguageModelRequest,
383 cx: &App,
384 ) -> BoxFuture<'static, Result<u64>> {
385 count_open_ai_tokens(request, self.model.clone(), cx)
386 }
387
388 fn stream_completion(
389 &self,
390 request: LanguageModelRequest,
391 cx: &AsyncApp,
392 ) -> BoxFuture<
393 'static,
394 Result<
395 futures::stream::BoxStream<
396 'static,
397 Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
398 >,
399 LanguageModelCompletionError,
400 >,
401 > {
402 if self.model.supports_chat_completions() {
403 let request = into_open_ai(
404 request,
405 self.model.id(),
406 self.model.supports_parallel_tool_calls(),
407 self.model.supports_prompt_cache_key(),
408 self.max_output_tokens(),
409 self.model.reasoning_effort(),
410 );
411 let completions = self.stream_completion(request, cx);
412 async move {
413 let mapper = OpenAiEventMapper::new();
414 Ok(mapper.map_stream(completions.await?).boxed())
415 }
416 .boxed()
417 } else {
418 let request = into_open_ai_response(
419 request,
420 self.model.id(),
421 self.model.supports_parallel_tool_calls(),
422 self.model.supports_prompt_cache_key(),
423 self.max_output_tokens(),
424 self.model.reasoning_effort(),
425 );
426 let completions = self.stream_response(request, cx);
427 async move {
428 let mapper = OpenAiResponseEventMapper::new();
429 Ok(mapper.map_stream(completions.await?).boxed())
430 }
431 .boxed()
432 }
433 }
434}
435
436pub fn into_open_ai(
437 request: LanguageModelRequest,
438 model_id: &str,
439 supports_parallel_tool_calls: bool,
440 supports_prompt_cache_key: bool,
441 max_output_tokens: Option<u64>,
442 reasoning_effort: Option<ReasoningEffort>,
443) -> open_ai::Request {
444 let stream = !model_id.starts_with("o1-");
445
446 let mut messages = Vec::new();
447 for message in request.messages {
448 for content in message.content {
449 match content {
450 MessageContent::Text(text) | MessageContent::Thinking { text, .. } => {
451 let should_add = if message.role == Role::User {
452 // Including whitespace-only user messages can cause error with OpenAI compatible APIs
453 // See https://github.com/zed-industries/zed/issues/40097
454 !text.trim().is_empty()
455 } else {
456 !text.is_empty()
457 };
458 if should_add {
459 add_message_content_part(
460 open_ai::MessagePart::Text { text },
461 message.role,
462 &mut messages,
463 );
464 }
465 }
466 MessageContent::RedactedThinking(_) => {}
467 MessageContent::Image(image) => {
468 add_message_content_part(
469 open_ai::MessagePart::Image {
470 image_url: ImageUrl {
471 url: image.to_base64_url(),
472 detail: None,
473 },
474 },
475 message.role,
476 &mut messages,
477 );
478 }
479 MessageContent::ToolUse(tool_use) => {
480 let tool_call = open_ai::ToolCall {
481 id: tool_use.id.to_string(),
482 content: open_ai::ToolCallContent::Function {
483 function: open_ai::FunctionContent {
484 name: tool_use.name.to_string(),
485 arguments: serde_json::to_string(&tool_use.input)
486 .unwrap_or_default(),
487 },
488 },
489 };
490
491 if let Some(open_ai::RequestMessage::Assistant { tool_calls, .. }) =
492 messages.last_mut()
493 {
494 tool_calls.push(tool_call);
495 } else {
496 messages.push(open_ai::RequestMessage::Assistant {
497 content: None,
498 tool_calls: vec![tool_call],
499 });
500 }
501 }
502 MessageContent::ToolResult(tool_result) => {
503 let content = match &tool_result.content {
504 LanguageModelToolResultContent::Text(text) => {
505 vec![open_ai::MessagePart::Text {
506 text: text.to_string(),
507 }]
508 }
509 LanguageModelToolResultContent::Image(image) => {
510 vec![open_ai::MessagePart::Image {
511 image_url: ImageUrl {
512 url: image.to_base64_url(),
513 detail: None,
514 },
515 }]
516 }
517 };
518
519 messages.push(open_ai::RequestMessage::Tool {
520 content: content.into(),
521 tool_call_id: tool_result.tool_use_id.to_string(),
522 });
523 }
524 }
525 }
526 }
527
528 open_ai::Request {
529 model: model_id.into(),
530 messages,
531 stream,
532 stream_options: if stream {
533 Some(open_ai::StreamOptions::default())
534 } else {
535 None
536 },
537 stop: request.stop,
538 temperature: request.temperature.or(Some(1.0)),
539 max_completion_tokens: max_output_tokens,
540 parallel_tool_calls: if supports_parallel_tool_calls && !request.tools.is_empty() {
541 Some(supports_parallel_tool_calls)
542 } else {
543 None
544 },
545 prompt_cache_key: if supports_prompt_cache_key {
546 request.thread_id
547 } else {
548 None
549 },
550 tools: request
551 .tools
552 .into_iter()
553 .map(|tool| open_ai::ToolDefinition::Function {
554 function: open_ai::FunctionDefinition {
555 name: tool.name,
556 description: Some(tool.description),
557 parameters: Some(tool.input_schema),
558 },
559 })
560 .collect(),
561 tool_choice: request.tool_choice.map(|choice| match choice {
562 LanguageModelToolChoice::Auto => open_ai::ToolChoice::Auto,
563 LanguageModelToolChoice::Any => open_ai::ToolChoice::Required,
564 LanguageModelToolChoice::None => open_ai::ToolChoice::None,
565 }),
566 reasoning_effort,
567 }
568}
569
570pub fn into_open_ai_response(
571 request: LanguageModelRequest,
572 model_id: &str,
573 supports_parallel_tool_calls: bool,
574 supports_prompt_cache_key: bool,
575 max_output_tokens: Option<u64>,
576 reasoning_effort: Option<ReasoningEffort>,
577) -> ResponseRequest {
578 let stream = !model_id.starts_with("o1-");
579
580 let LanguageModelRequest {
581 thread_id,
582 prompt_id: _,
583 intent: _,
584 messages,
585 tools,
586 tool_choice,
587 stop: _,
588 temperature,
589 thinking_allowed: _,
590 thinking_effort: _,
591 speed: _,
592 } = request;
593
594 let mut input_items = Vec::new();
595 for (index, message) in messages.into_iter().enumerate() {
596 append_message_to_response_items(message, index, &mut input_items);
597 }
598
599 let tools: Vec<_> = tools
600 .into_iter()
601 .map(|tool| open_ai::responses::ToolDefinition::Function {
602 name: tool.name,
603 description: Some(tool.description),
604 parameters: Some(tool.input_schema),
605 strict: None,
606 })
607 .collect();
608
609 ResponseRequest {
610 model: model_id.into(),
611 input: input_items,
612 stream,
613 temperature,
614 top_p: None,
615 max_output_tokens,
616 parallel_tool_calls: if tools.is_empty() {
617 None
618 } else {
619 Some(supports_parallel_tool_calls)
620 },
621 tool_choice: tool_choice.map(|choice| match choice {
622 LanguageModelToolChoice::Auto => open_ai::ToolChoice::Auto,
623 LanguageModelToolChoice::Any => open_ai::ToolChoice::Required,
624 LanguageModelToolChoice::None => open_ai::ToolChoice::None,
625 }),
626 tools,
627 prompt_cache_key: if supports_prompt_cache_key {
628 thread_id
629 } else {
630 None
631 },
632 reasoning: reasoning_effort.map(|effort| open_ai::responses::ReasoningConfig {
633 effort,
634 summary: Some(open_ai::responses::ReasoningSummaryMode::Auto),
635 }),
636 }
637}
638
639fn append_message_to_response_items(
640 message: LanguageModelRequestMessage,
641 index: usize,
642 input_items: &mut Vec<ResponseInputItem>,
643) {
644 let mut content_parts: Vec<ResponseInputContent> = Vec::new();
645
646 for content in message.content {
647 match content {
648 MessageContent::Text(text) => {
649 push_response_text_part(&message.role, text, &mut content_parts);
650 }
651 MessageContent::Thinking { text, .. } => {
652 push_response_text_part(&message.role, text, &mut content_parts);
653 }
654 MessageContent::RedactedThinking(_) => {}
655 MessageContent::Image(image) => {
656 push_response_image_part(&message.role, image, &mut content_parts);
657 }
658 MessageContent::ToolUse(tool_use) => {
659 flush_response_parts(&message.role, index, &mut content_parts, input_items);
660 let call_id = tool_use.id.to_string();
661 input_items.push(ResponseInputItem::FunctionCall(ResponseFunctionCallItem {
662 call_id,
663 name: tool_use.name.to_string(),
664 arguments: tool_use.raw_input,
665 }));
666 }
667 MessageContent::ToolResult(tool_result) => {
668 flush_response_parts(&message.role, index, &mut content_parts, input_items);
669 input_items.push(ResponseInputItem::FunctionCallOutput(
670 ResponseFunctionCallOutputItem {
671 call_id: tool_result.tool_use_id.to_string(),
672 output: match tool_result.content {
673 LanguageModelToolResultContent::Text(text) => {
674 ResponseFunctionCallOutputContent::Text(text.to_string())
675 }
676 LanguageModelToolResultContent::Image(image) => {
677 ResponseFunctionCallOutputContent::List(vec![
678 ResponseInputContent::Image {
679 image_url: image.to_base64_url(),
680 },
681 ])
682 }
683 },
684 },
685 ));
686 }
687 }
688 }
689
690 flush_response_parts(&message.role, index, &mut content_parts, input_items);
691}
692
693fn push_response_text_part(
694 role: &Role,
695 text: impl Into<String>,
696 parts: &mut Vec<ResponseInputContent>,
697) {
698 let text = text.into();
699 if text.trim().is_empty() {
700 return;
701 }
702
703 match role {
704 Role::Assistant => parts.push(ResponseInputContent::OutputText {
705 text,
706 annotations: Vec::new(),
707 }),
708 _ => parts.push(ResponseInputContent::Text { text }),
709 }
710}
711
712fn push_response_image_part(
713 role: &Role,
714 image: LanguageModelImage,
715 parts: &mut Vec<ResponseInputContent>,
716) {
717 match role {
718 Role::Assistant => parts.push(ResponseInputContent::OutputText {
719 text: "[image omitted]".to_string(),
720 annotations: Vec::new(),
721 }),
722 _ => parts.push(ResponseInputContent::Image {
723 image_url: image.to_base64_url(),
724 }),
725 }
726}
727
728fn flush_response_parts(
729 role: &Role,
730 _index: usize,
731 parts: &mut Vec<ResponseInputContent>,
732 input_items: &mut Vec<ResponseInputItem>,
733) {
734 if parts.is_empty() {
735 return;
736 }
737
738 let item = ResponseInputItem::Message(ResponseMessageItem {
739 role: match role {
740 Role::User => open_ai::Role::User,
741 Role::Assistant => open_ai::Role::Assistant,
742 Role::System => open_ai::Role::System,
743 },
744 content: parts.clone(),
745 });
746
747 input_items.push(item);
748 parts.clear();
749}
750
751fn add_message_content_part(
752 new_part: open_ai::MessagePart,
753 role: Role,
754 messages: &mut Vec<open_ai::RequestMessage>,
755) {
756 match (role, messages.last_mut()) {
757 (Role::User, Some(open_ai::RequestMessage::User { content }))
758 | (
759 Role::Assistant,
760 Some(open_ai::RequestMessage::Assistant {
761 content: Some(content),
762 ..
763 }),
764 )
765 | (Role::System, Some(open_ai::RequestMessage::System { content, .. })) => {
766 content.push_part(new_part);
767 }
768 _ => {
769 messages.push(match role {
770 Role::User => open_ai::RequestMessage::User {
771 content: open_ai::MessageContent::from(vec![new_part]),
772 },
773 Role::Assistant => open_ai::RequestMessage::Assistant {
774 content: Some(open_ai::MessageContent::from(vec![new_part])),
775 tool_calls: Vec::new(),
776 },
777 Role::System => open_ai::RequestMessage::System {
778 content: open_ai::MessageContent::from(vec![new_part]),
779 },
780 });
781 }
782 }
783}
784
785pub struct OpenAiEventMapper {
786 tool_calls_by_index: HashMap<usize, RawToolCall>,
787}
788
789impl OpenAiEventMapper {
790 pub fn new() -> Self {
791 Self {
792 tool_calls_by_index: HashMap::default(),
793 }
794 }
795
796 pub fn map_stream(
797 mut self,
798 events: Pin<Box<dyn Send + Stream<Item = Result<ResponseStreamEvent>>>>,
799 ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
800 {
801 events.flat_map(move |event| {
802 futures::stream::iter(match event {
803 Ok(event) => self.map_event(event),
804 Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))],
805 })
806 })
807 }
808
809 pub fn map_event(
810 &mut self,
811 event: ResponseStreamEvent,
812 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
813 let mut events = Vec::new();
814 if let Some(usage) = event.usage {
815 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
816 input_tokens: usage.prompt_tokens,
817 output_tokens: usage.completion_tokens,
818 cache_creation_input_tokens: 0,
819 cache_read_input_tokens: 0,
820 })));
821 }
822
823 let Some(choice) = event.choices.first() else {
824 return events;
825 };
826
827 if let Some(delta) = choice.delta.as_ref() {
828 if let Some(reasoning_content) = delta.reasoning_content.clone() {
829 if !reasoning_content.is_empty() {
830 events.push(Ok(LanguageModelCompletionEvent::Thinking {
831 text: reasoning_content,
832 signature: None,
833 }));
834 }
835 }
836 if let Some(content) = delta.content.clone() {
837 if !content.is_empty() {
838 events.push(Ok(LanguageModelCompletionEvent::Text(content)));
839 }
840 }
841
842 if let Some(tool_calls) = delta.tool_calls.as_ref() {
843 for tool_call in tool_calls {
844 let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
845
846 if let Some(tool_id) = tool_call.id.clone() {
847 entry.id = tool_id;
848 }
849
850 if let Some(function) = tool_call.function.as_ref() {
851 if let Some(name) = function.name.clone() {
852 entry.name = name;
853 }
854
855 if let Some(arguments) = function.arguments.clone() {
856 entry.arguments.push_str(&arguments);
857 }
858 }
859
860 if !entry.id.is_empty() && !entry.name.is_empty() {
861 if let Ok(input) = serde_json::from_str::<serde_json::Value>(
862 &fix_streamed_json(&entry.arguments),
863 ) {
864 events.push(Ok(LanguageModelCompletionEvent::ToolUse(
865 LanguageModelToolUse {
866 id: entry.id.clone().into(),
867 name: entry.name.as_str().into(),
868 is_input_complete: false,
869 input,
870 raw_input: entry.arguments.clone(),
871 thought_signature: None,
872 },
873 )));
874 }
875 }
876 }
877 }
878 }
879
880 match choice.finish_reason.as_deref() {
881 Some("stop") => {
882 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
883 }
884 Some("tool_calls") => {
885 events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
886 match parse_tool_arguments(&tool_call.arguments) {
887 Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
888 LanguageModelToolUse {
889 id: tool_call.id.clone().into(),
890 name: tool_call.name.as_str().into(),
891 is_input_complete: true,
892 input,
893 raw_input: tool_call.arguments.clone(),
894 thought_signature: None,
895 },
896 )),
897 Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
898 id: tool_call.id.into(),
899 tool_name: tool_call.name.into(),
900 raw_input: tool_call.arguments.clone().into(),
901 json_parse_error: error.to_string(),
902 }),
903 }
904 }));
905
906 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
907 }
908 Some(stop_reason) => {
909 log::error!("Unexpected OpenAI stop_reason: {stop_reason:?}",);
910 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
911 }
912 None => {}
913 }
914
915 events
916 }
917}
918
919#[derive(Default)]
920struct RawToolCall {
921 id: String,
922 name: String,
923 arguments: String,
924}
925
926pub struct OpenAiResponseEventMapper {
927 function_calls_by_item: HashMap<String, PendingResponseFunctionCall>,
928 pending_stop_reason: Option<StopReason>,
929}
930
931#[derive(Default)]
932struct PendingResponseFunctionCall {
933 call_id: String,
934 name: Arc<str>,
935 arguments: String,
936}
937
938impl OpenAiResponseEventMapper {
939 pub fn new() -> Self {
940 Self {
941 function_calls_by_item: HashMap::default(),
942 pending_stop_reason: None,
943 }
944 }
945
946 pub fn map_stream(
947 mut self,
948 events: Pin<Box<dyn Send + Stream<Item = Result<ResponsesStreamEvent>>>>,
949 ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
950 {
951 events.flat_map(move |event| {
952 futures::stream::iter(match event {
953 Ok(event) => self.map_event(event),
954 Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))],
955 })
956 })
957 }
958
959 pub fn map_event(
960 &mut self,
961 event: ResponsesStreamEvent,
962 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
963 match event {
964 ResponsesStreamEvent::OutputItemAdded { item, .. } => {
965 let mut events = Vec::new();
966
967 match &item {
968 ResponseOutputItem::Message(message) => {
969 if let Some(id) = &message.id {
970 events.push(Ok(LanguageModelCompletionEvent::StartMessage {
971 message_id: id.clone(),
972 }));
973 }
974 }
975 ResponseOutputItem::FunctionCall(function_call) => {
976 if let Some(item_id) = function_call.id.clone() {
977 let call_id = function_call
978 .call_id
979 .clone()
980 .or_else(|| function_call.id.clone())
981 .unwrap_or_else(|| item_id.clone());
982 let entry = PendingResponseFunctionCall {
983 call_id,
984 name: Arc::<str>::from(
985 function_call.name.clone().unwrap_or_default(),
986 ),
987 arguments: function_call.arguments.clone(),
988 };
989 self.function_calls_by_item.insert(item_id, entry);
990 }
991 }
992 ResponseOutputItem::Reasoning(_) | ResponseOutputItem::Unknown => {}
993 }
994 events
995 }
996 ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => {
997 if delta.is_empty() {
998 Vec::new()
999 } else {
1000 vec![Ok(LanguageModelCompletionEvent::Thinking {
1001 text: delta,
1002 signature: None,
1003 })]
1004 }
1005 }
1006 ResponsesStreamEvent::OutputTextDelta { delta, .. } => {
1007 if delta.is_empty() {
1008 Vec::new()
1009 } else {
1010 vec![Ok(LanguageModelCompletionEvent::Text(delta))]
1011 }
1012 }
1013 ResponsesStreamEvent::FunctionCallArgumentsDelta { item_id, delta, .. } => {
1014 if let Some(entry) = self.function_calls_by_item.get_mut(&item_id) {
1015 entry.arguments.push_str(&delta);
1016 if let Ok(input) = serde_json::from_str::<serde_json::Value>(
1017 &fix_streamed_json(&entry.arguments),
1018 ) {
1019 return vec![Ok(LanguageModelCompletionEvent::ToolUse(
1020 LanguageModelToolUse {
1021 id: LanguageModelToolUseId::from(entry.call_id.clone()),
1022 name: entry.name.clone(),
1023 is_input_complete: false,
1024 input,
1025 raw_input: entry.arguments.clone(),
1026 thought_signature: None,
1027 },
1028 ))];
1029 }
1030 }
1031 Vec::new()
1032 }
1033 ResponsesStreamEvent::FunctionCallArgumentsDone {
1034 item_id, arguments, ..
1035 } => {
1036 if let Some(mut entry) = self.function_calls_by_item.remove(&item_id) {
1037 if !arguments.is_empty() {
1038 entry.arguments = arguments;
1039 }
1040 let raw_input = entry.arguments.clone();
1041 self.pending_stop_reason = Some(StopReason::ToolUse);
1042 match parse_tool_arguments(&entry.arguments) {
1043 Ok(input) => {
1044 vec![Ok(LanguageModelCompletionEvent::ToolUse(
1045 LanguageModelToolUse {
1046 id: LanguageModelToolUseId::from(entry.call_id.clone()),
1047 name: entry.name.clone(),
1048 is_input_complete: true,
1049 input,
1050 raw_input,
1051 thought_signature: None,
1052 },
1053 ))]
1054 }
1055 Err(error) => {
1056 vec![Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
1057 id: LanguageModelToolUseId::from(entry.call_id.clone()),
1058 tool_name: entry.name.clone(),
1059 raw_input: Arc::<str>::from(raw_input),
1060 json_parse_error: error.to_string(),
1061 })]
1062 }
1063 }
1064 } else {
1065 Vec::new()
1066 }
1067 }
1068 ResponsesStreamEvent::Completed { response } => {
1069 self.handle_completion(response, StopReason::EndTurn)
1070 }
1071 ResponsesStreamEvent::Incomplete { response } => {
1072 let reason = response
1073 .status_details
1074 .as_ref()
1075 .and_then(|details| details.reason.as_deref());
1076 let stop_reason = match reason {
1077 Some("max_output_tokens") => StopReason::MaxTokens,
1078 Some("content_filter") => {
1079 self.pending_stop_reason = Some(StopReason::Refusal);
1080 StopReason::Refusal
1081 }
1082 _ => self
1083 .pending_stop_reason
1084 .take()
1085 .unwrap_or(StopReason::EndTurn),
1086 };
1087
1088 let mut events = Vec::new();
1089 if self.pending_stop_reason.is_none() {
1090 events.extend(self.emit_tool_calls_from_output(&response.output));
1091 }
1092 if let Some(usage) = response.usage.as_ref() {
1093 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(
1094 token_usage_from_response_usage(usage),
1095 )));
1096 }
1097 events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason)));
1098 events
1099 }
1100 ResponsesStreamEvent::Failed { response } => {
1101 let message = response
1102 .status_details
1103 .and_then(|details| details.error)
1104 .map(|error| error.to_string())
1105 .unwrap_or_else(|| "response failed".to_string());
1106 vec![Err(LanguageModelCompletionError::Other(anyhow!(message)))]
1107 }
1108 ResponsesStreamEvent::Error { error }
1109 | ResponsesStreamEvent::GenericError { error } => {
1110 vec![Err(LanguageModelCompletionError::Other(anyhow!(
1111 error.message
1112 )))]
1113 }
1114 ResponsesStreamEvent::ReasoningSummaryPartAdded { summary_index, .. } => {
1115 if summary_index > 0 {
1116 vec![Ok(LanguageModelCompletionEvent::Thinking {
1117 text: "\n\n".to_string(),
1118 signature: None,
1119 })]
1120 } else {
1121 Vec::new()
1122 }
1123 }
1124 ResponsesStreamEvent::OutputTextDone { .. }
1125 | ResponsesStreamEvent::OutputItemDone { .. }
1126 | ResponsesStreamEvent::ContentPartAdded { .. }
1127 | ResponsesStreamEvent::ContentPartDone { .. }
1128 | ResponsesStreamEvent::ReasoningSummaryTextDone { .. }
1129 | ResponsesStreamEvent::ReasoningSummaryPartDone { .. }
1130 | ResponsesStreamEvent::Created { .. }
1131 | ResponsesStreamEvent::InProgress { .. }
1132 | ResponsesStreamEvent::Unknown => Vec::new(),
1133 }
1134 }
1135
1136 fn handle_completion(
1137 &mut self,
1138 response: ResponsesSummary,
1139 default_reason: StopReason,
1140 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1141 let mut events = Vec::new();
1142
1143 if self.pending_stop_reason.is_none() {
1144 events.extend(self.emit_tool_calls_from_output(&response.output));
1145 }
1146
1147 if let Some(usage) = response.usage.as_ref() {
1148 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(
1149 token_usage_from_response_usage(usage),
1150 )));
1151 }
1152
1153 let stop_reason = self.pending_stop_reason.take().unwrap_or(default_reason);
1154 events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason)));
1155 events
1156 }
1157
1158 fn emit_tool_calls_from_output(
1159 &mut self,
1160 output: &[ResponseOutputItem],
1161 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
1162 let mut events = Vec::new();
1163 for item in output {
1164 if let ResponseOutputItem::FunctionCall(function_call) = item {
1165 let Some(call_id) = function_call
1166 .call_id
1167 .clone()
1168 .or_else(|| function_call.id.clone())
1169 else {
1170 log::error!(
1171 "Function call item missing both call_id and id: {:?}",
1172 function_call
1173 );
1174 continue;
1175 };
1176 let name: Arc<str> = Arc::from(function_call.name.clone().unwrap_or_default());
1177 let arguments = &function_call.arguments;
1178 self.pending_stop_reason = Some(StopReason::ToolUse);
1179 match parse_tool_arguments(arguments) {
1180 Ok(input) => {
1181 events.push(Ok(LanguageModelCompletionEvent::ToolUse(
1182 LanguageModelToolUse {
1183 id: LanguageModelToolUseId::from(call_id.clone()),
1184 name: name.clone(),
1185 is_input_complete: true,
1186 input,
1187 raw_input: arguments.clone(),
1188 thought_signature: None,
1189 },
1190 )));
1191 }
1192 Err(error) => {
1193 events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
1194 id: LanguageModelToolUseId::from(call_id.clone()),
1195 tool_name: name.clone(),
1196 raw_input: Arc::<str>::from(arguments.clone()),
1197 json_parse_error: error.to_string(),
1198 }));
1199 }
1200 }
1201 }
1202 }
1203 events
1204 }
1205}
1206
1207fn token_usage_from_response_usage(usage: &ResponsesUsage) -> TokenUsage {
1208 TokenUsage {
1209 input_tokens: usage.input_tokens.unwrap_or_default(),
1210 output_tokens: usage.output_tokens.unwrap_or_default(),
1211 cache_creation_input_tokens: 0,
1212 cache_read_input_tokens: 0,
1213 }
1214}
1215
1216pub(crate) fn collect_tiktoken_messages(
1217 request: LanguageModelRequest,
1218) -> Vec<tiktoken_rs::ChatCompletionRequestMessage> {
1219 request
1220 .messages
1221 .into_iter()
1222 .map(|message| tiktoken_rs::ChatCompletionRequestMessage {
1223 role: match message.role {
1224 Role::User => "user".into(),
1225 Role::Assistant => "assistant".into(),
1226 Role::System => "system".into(),
1227 },
1228 content: Some(message.string_contents()),
1229 name: None,
1230 function_call: None,
1231 })
1232 .collect::<Vec<_>>()
1233}
1234
1235pub fn count_open_ai_tokens(
1236 request: LanguageModelRequest,
1237 model: Model,
1238 cx: &App,
1239) -> BoxFuture<'static, Result<u64>> {
1240 cx.background_spawn(async move {
1241 let messages = collect_tiktoken_messages(request);
1242 match model {
1243 Model::Custom { max_tokens, .. } => {
1244 let model = if max_tokens >= 100_000 {
1245 // If the max tokens is 100k or more, it likely uses the o200k_base tokenizer
1246 "gpt-4o"
1247 } else {
1248 // Otherwise fallback to gpt-4, since only cl100k_base and o200k_base are
1249 // supported with this tiktoken method
1250 "gpt-4"
1251 };
1252 tiktoken_rs::num_tokens_from_messages(model, &messages)
1253 }
1254 // Currently supported by tiktoken_rs
1255 // Sometimes tiktoken-rs is behind on model support. If that is the case, make a new branch
1256 // arm with an override. We enumerate all supported models here so that we can check if new
1257 // models are supported yet or not.
1258 Model::ThreePointFiveTurbo
1259 | Model::Four
1260 | Model::FourTurbo
1261 | Model::FourOmniMini
1262 | Model::FourPointOneNano
1263 | Model::O1
1264 | Model::O3
1265 | Model::O3Mini
1266 | Model::Five
1267 | Model::FiveCodex
1268 | Model::FiveMini
1269 | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages),
1270 // GPT-5.1, 5.2, 5.2-codex, 5.3-codex, 5.4, and 5.4-pro don't have dedicated tiktoken support; use gpt-5 tokenizer
1271 Model::FivePointOne
1272 | Model::FivePointTwo
1273 | Model::FivePointTwoCodex
1274 | Model::FivePointThreeCodex
1275 | Model::FivePointFour
1276 | Model::FivePointFourPro => tiktoken_rs::num_tokens_from_messages("gpt-5", &messages),
1277 }
1278 .map(|tokens| tokens as u64)
1279 })
1280 .boxed()
1281}
1282
1283struct ConfigurationView {
1284 api_key_editor: Entity<InputField>,
1285 state: Entity<State>,
1286 load_credentials_task: Option<Task<()>>,
1287}
1288
1289impl ConfigurationView {
1290 fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
1291 let api_key_editor = cx.new(|cx| {
1292 InputField::new(
1293 window,
1294 cx,
1295 "sk-000000000000000000000000000000000000000000000000",
1296 )
1297 });
1298
1299 cx.observe(&state, |_, _, cx| {
1300 cx.notify();
1301 })
1302 .detach();
1303
1304 let load_credentials_task = Some(cx.spawn_in(window, {
1305 let state = state.clone();
1306 async move |this, cx| {
1307 if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
1308 // We don't log an error, because "not signed in" is also an error.
1309 let _ = task.await;
1310 }
1311 this.update(cx, |this, cx| {
1312 this.load_credentials_task = None;
1313 cx.notify();
1314 })
1315 .log_err();
1316 }
1317 }));
1318
1319 Self {
1320 api_key_editor,
1321 state,
1322 load_credentials_task,
1323 }
1324 }
1325
1326 fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1327 let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
1328 if api_key.is_empty() {
1329 return;
1330 }
1331
1332 // url changes can cause the editor to be displayed again
1333 self.api_key_editor
1334 .update(cx, |editor, cx| editor.set_text("", window, cx));
1335
1336 let state = self.state.clone();
1337 cx.spawn_in(window, async move |_, cx| {
1338 state
1339 .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
1340 .await
1341 })
1342 .detach_and_log_err(cx);
1343 }
1344
1345 fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1346 self.api_key_editor
1347 .update(cx, |input, cx| input.set_text("", window, cx));
1348
1349 let state = self.state.clone();
1350 cx.spawn_in(window, async move |_, cx| {
1351 state
1352 .update(cx, |state, cx| state.set_api_key(None, cx))
1353 .await
1354 })
1355 .detach_and_log_err(cx);
1356 }
1357
1358 fn should_render_editor(&self, cx: &mut Context<Self>) -> bool {
1359 !self.state.read(cx).is_authenticated()
1360 }
1361}
1362
1363impl Render for ConfigurationView {
1364 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1365 let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
1366 let configured_card_label = if env_var_set {
1367 format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
1368 } else {
1369 let api_url = OpenAiLanguageModelProvider::api_url(cx);
1370 if api_url == OPEN_AI_API_URL {
1371 "API key configured".to_string()
1372 } else {
1373 format!("API key configured for {}", api_url)
1374 }
1375 };
1376
1377 let api_key_section = if self.should_render_editor(cx) {
1378 v_flex()
1379 .on_action(cx.listener(Self::save_api_key))
1380 .child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:"))
1381 .child(
1382 List::new()
1383 .child(
1384 ListBulletItem::new("")
1385 .child(Label::new("Create one by visiting"))
1386 .child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys"))
1387 )
1388 .child(
1389 ListBulletItem::new("Ensure your OpenAI account has credits")
1390 )
1391 .child(
1392 ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
1393 ),
1394 )
1395 .child(self.api_key_editor.clone())
1396 .child(
1397 Label::new(format!(
1398 "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."
1399 ))
1400 .size(LabelSize::Small)
1401 .color(Color::Muted),
1402 )
1403 .child(
1404 Label::new(
1405 "Note that having a subscription for another service like GitHub Copilot won't work.",
1406 )
1407 .size(LabelSize::Small).color(Color::Muted),
1408 )
1409 .into_any_element()
1410 } else {
1411 ConfiguredApiCard::new(configured_card_label)
1412 .disabled(env_var_set)
1413 .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
1414 .when(env_var_set, |this| {
1415 this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
1416 })
1417 .into_any_element()
1418 };
1419
1420 let compatible_api_section = h_flex()
1421 .mt_1p5()
1422 .gap_0p5()
1423 .flex_wrap()
1424 .when(self.should_render_editor(cx), |this| {
1425 this.pt_1p5()
1426 .border_t_1()
1427 .border_color(cx.theme().colors().border_variant)
1428 })
1429 .child(
1430 h_flex()
1431 .gap_2()
1432 .child(
1433 Icon::new(IconName::Info)
1434 .size(IconSize::XSmall)
1435 .color(Color::Muted),
1436 )
1437 .child(Label::new("Zed also supports OpenAI-compatible models.")),
1438 )
1439 .child(
1440 Button::new("docs", "Learn More")
1441 .end_icon(
1442 Icon::new(IconName::ArrowUpRight)
1443 .size(IconSize::Small)
1444 .color(Color::Muted),
1445 )
1446 .on_click(move |_, _window, cx| {
1447 cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible")
1448 }),
1449 );
1450
1451 if self.load_credentials_task.is_some() {
1452 div().child(Label::new("Loading credentials…")).into_any()
1453 } else {
1454 v_flex()
1455 .size_full()
1456 .child(api_key_section)
1457 .child(compatible_api_section)
1458 .into_any()
1459 }
1460 }
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465 use futures::{StreamExt, executor::block_on};
1466 use gpui::TestAppContext;
1467 use language_model::{
1468 LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult,
1469 };
1470 use open_ai::responses::{
1471 ReasoningSummaryPart, ResponseFunctionToolCall, ResponseOutputItem, ResponseOutputMessage,
1472 ResponseReasoningItem, ResponseStatusDetails, ResponseSummary, ResponseUsage,
1473 StreamEvent as ResponsesStreamEvent,
1474 };
1475 use pretty_assertions::assert_eq;
1476 use serde_json::json;
1477
1478 use super::*;
1479
1480 fn map_response_events(events: Vec<ResponsesStreamEvent>) -> Vec<LanguageModelCompletionEvent> {
1481 block_on(async {
1482 OpenAiResponseEventMapper::new()
1483 .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
1484 .collect::<Vec<_>>()
1485 .await
1486 .into_iter()
1487 .map(Result::unwrap)
1488 .collect()
1489 })
1490 }
1491
1492 fn response_item_message(id: &str) -> ResponseOutputItem {
1493 ResponseOutputItem::Message(ResponseOutputMessage {
1494 id: Some(id.to_string()),
1495 role: Some("assistant".to_string()),
1496 status: Some("in_progress".to_string()),
1497 content: vec![],
1498 })
1499 }
1500
1501 fn response_item_function_call(id: &str, args: Option<&str>) -> ResponseOutputItem {
1502 ResponseOutputItem::FunctionCall(ResponseFunctionToolCall {
1503 id: Some(id.to_string()),
1504 status: Some("in_progress".to_string()),
1505 name: Some("get_weather".to_string()),
1506 call_id: Some("call_123".to_string()),
1507 arguments: args.map(|s| s.to_string()).unwrap_or_default(),
1508 })
1509 }
1510
1511 #[gpui::test]
1512 fn tiktoken_rs_support(cx: &TestAppContext) {
1513 let request = LanguageModelRequest {
1514 thread_id: None,
1515 prompt_id: None,
1516 intent: None,
1517 messages: vec![LanguageModelRequestMessage {
1518 role: Role::User,
1519 content: vec![MessageContent::Text("message".into())],
1520 cache: false,
1521 reasoning_details: None,
1522 }],
1523 tools: vec![],
1524 tool_choice: None,
1525 stop: vec![],
1526 temperature: None,
1527 thinking_allowed: true,
1528 thinking_effort: None,
1529 speed: None,
1530 };
1531
1532 // Validate that all models are supported by tiktoken-rs
1533 for model in Model::iter() {
1534 let count = cx
1535 .foreground_executor()
1536 .block_on(count_open_ai_tokens(
1537 request.clone(),
1538 model,
1539 &cx.app.borrow(),
1540 ))
1541 .unwrap();
1542 assert!(count > 0);
1543 }
1544 }
1545
1546 #[test]
1547 fn responses_stream_maps_text_and_usage() {
1548 let events = vec![
1549 ResponsesStreamEvent::OutputItemAdded {
1550 output_index: 0,
1551 sequence_number: None,
1552 item: response_item_message("msg_123"),
1553 },
1554 ResponsesStreamEvent::OutputTextDelta {
1555 item_id: "msg_123".into(),
1556 output_index: 0,
1557 content_index: Some(0),
1558 delta: "Hello".into(),
1559 },
1560 ResponsesStreamEvent::Completed {
1561 response: ResponseSummary {
1562 usage: Some(ResponseUsage {
1563 input_tokens: Some(5),
1564 output_tokens: Some(3),
1565 total_tokens: Some(8),
1566 }),
1567 ..Default::default()
1568 },
1569 },
1570 ];
1571
1572 let mapped = map_response_events(events);
1573 assert!(matches!(
1574 mapped[0],
1575 LanguageModelCompletionEvent::StartMessage { ref message_id } if message_id == "msg_123"
1576 ));
1577 assert!(matches!(
1578 mapped[1],
1579 LanguageModelCompletionEvent::Text(ref text) if text == "Hello"
1580 ));
1581 assert!(matches!(
1582 mapped[2],
1583 LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
1584 input_tokens: 5,
1585 output_tokens: 3,
1586 ..
1587 })
1588 ));
1589 assert!(matches!(
1590 mapped[3],
1591 LanguageModelCompletionEvent::Stop(StopReason::EndTurn)
1592 ));
1593 }
1594
1595 #[test]
1596 fn into_open_ai_response_builds_complete_payload() {
1597 let tool_call_id = LanguageModelToolUseId::from("call-42");
1598 let tool_input = json!({ "city": "Boston" });
1599 let tool_arguments = serde_json::to_string(&tool_input).unwrap();
1600 let tool_use = LanguageModelToolUse {
1601 id: tool_call_id.clone(),
1602 name: Arc::from("get_weather"),
1603 raw_input: tool_arguments.clone(),
1604 input: tool_input,
1605 is_input_complete: true,
1606 thought_signature: None,
1607 };
1608 let tool_result = LanguageModelToolResult {
1609 tool_use_id: tool_call_id,
1610 tool_name: Arc::from("get_weather"),
1611 is_error: false,
1612 content: LanguageModelToolResultContent::Text(Arc::from("Sunny")),
1613 output: Some(json!({ "forecast": "Sunny" })),
1614 };
1615 let user_image = LanguageModelImage {
1616 source: SharedString::from("aGVsbG8="),
1617 size: None,
1618 };
1619 let expected_image_url = user_image.to_base64_url();
1620
1621 let request = LanguageModelRequest {
1622 thread_id: Some("thread-123".into()),
1623 prompt_id: None,
1624 intent: None,
1625 messages: vec![
1626 LanguageModelRequestMessage {
1627 role: Role::System,
1628 content: vec![MessageContent::Text("System context".into())],
1629 cache: false,
1630 reasoning_details: None,
1631 },
1632 LanguageModelRequestMessage {
1633 role: Role::User,
1634 content: vec![
1635 MessageContent::Text("Please check the weather.".into()),
1636 MessageContent::Image(user_image),
1637 ],
1638 cache: false,
1639 reasoning_details: None,
1640 },
1641 LanguageModelRequestMessage {
1642 role: Role::Assistant,
1643 content: vec![
1644 MessageContent::Text("Looking that up.".into()),
1645 MessageContent::ToolUse(tool_use),
1646 ],
1647 cache: false,
1648 reasoning_details: None,
1649 },
1650 LanguageModelRequestMessage {
1651 role: Role::Assistant,
1652 content: vec![MessageContent::ToolResult(tool_result)],
1653 cache: false,
1654 reasoning_details: None,
1655 },
1656 ],
1657 tools: vec![LanguageModelRequestTool {
1658 name: "get_weather".into(),
1659 description: "Fetches the weather".into(),
1660 input_schema: json!({ "type": "object" }),
1661 use_input_streaming: false,
1662 }],
1663 tool_choice: Some(LanguageModelToolChoice::Any),
1664 stop: vec!["<STOP>".into()],
1665 temperature: None,
1666 thinking_allowed: false,
1667 thinking_effort: None,
1668 speed: None,
1669 };
1670
1671 let response = into_open_ai_response(
1672 request,
1673 "custom-model",
1674 true,
1675 true,
1676 Some(2048),
1677 Some(ReasoningEffort::Low),
1678 );
1679
1680 let serialized = serde_json::to_value(&response).unwrap();
1681 let expected = json!({
1682 "model": "custom-model",
1683 "input": [
1684 {
1685 "type": "message",
1686 "role": "system",
1687 "content": [
1688 { "type": "input_text", "text": "System context" }
1689 ]
1690 },
1691 {
1692 "type": "message",
1693 "role": "user",
1694 "content": [
1695 { "type": "input_text", "text": "Please check the weather." },
1696 { "type": "input_image", "image_url": expected_image_url }
1697 ]
1698 },
1699 {
1700 "type": "message",
1701 "role": "assistant",
1702 "content": [
1703 { "type": "output_text", "text": "Looking that up.", "annotations": [] }
1704 ]
1705 },
1706 {
1707 "type": "function_call",
1708 "call_id": "call-42",
1709 "name": "get_weather",
1710 "arguments": tool_arguments
1711 },
1712 {
1713 "type": "function_call_output",
1714 "call_id": "call-42",
1715 "output": "Sunny"
1716 }
1717 ],
1718 "stream": true,
1719 "max_output_tokens": 2048,
1720 "parallel_tool_calls": true,
1721 "tool_choice": "required",
1722 "tools": [
1723 {
1724 "type": "function",
1725 "name": "get_weather",
1726 "description": "Fetches the weather",
1727 "parameters": { "type": "object" }
1728 }
1729 ],
1730 "prompt_cache_key": "thread-123",
1731 "reasoning": { "effort": "low", "summary": "auto" }
1732 });
1733
1734 assert_eq!(serialized, expected);
1735 }
1736
1737 #[test]
1738 fn responses_stream_maps_tool_calls() {
1739 let events = vec![
1740 ResponsesStreamEvent::OutputItemAdded {
1741 output_index: 0,
1742 sequence_number: None,
1743 item: response_item_function_call("item_fn", Some("{\"city\":\"Bos")),
1744 },
1745 ResponsesStreamEvent::FunctionCallArgumentsDelta {
1746 item_id: "item_fn".into(),
1747 output_index: 0,
1748 delta: "ton\"}".into(),
1749 sequence_number: None,
1750 },
1751 ResponsesStreamEvent::FunctionCallArgumentsDone {
1752 item_id: "item_fn".into(),
1753 output_index: 0,
1754 arguments: "{\"city\":\"Boston\"}".into(),
1755 sequence_number: None,
1756 },
1757 ResponsesStreamEvent::Completed {
1758 response: ResponseSummary::default(),
1759 },
1760 ];
1761
1762 let mapped = map_response_events(events);
1763 assert_eq!(mapped.len(), 3);
1764 // First event is the partial tool use (from FunctionCallArgumentsDelta)
1765 assert!(matches!(
1766 mapped[0],
1767 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
1768 is_input_complete: false,
1769 ..
1770 })
1771 ));
1772 // Second event is the complete tool use (from FunctionCallArgumentsDone)
1773 assert!(matches!(
1774 mapped[1],
1775 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
1776 ref id,
1777 ref name,
1778 ref raw_input,
1779 is_input_complete: true,
1780 ..
1781 }) if id.to_string() == "call_123"
1782 && name.as_ref() == "get_weather"
1783 && raw_input == "{\"city\":\"Boston\"}"
1784 ));
1785 assert!(matches!(
1786 mapped[2],
1787 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
1788 ));
1789 }
1790
1791 #[test]
1792 fn responses_stream_uses_max_tokens_stop_reason() {
1793 let events = vec![ResponsesStreamEvent::Incomplete {
1794 response: ResponseSummary {
1795 status_details: Some(ResponseStatusDetails {
1796 reason: Some("max_output_tokens".into()),
1797 r#type: Some("incomplete".into()),
1798 error: None,
1799 }),
1800 usage: Some(ResponseUsage {
1801 input_tokens: Some(10),
1802 output_tokens: Some(20),
1803 total_tokens: Some(30),
1804 }),
1805 ..Default::default()
1806 },
1807 }];
1808
1809 let mapped = map_response_events(events);
1810 assert!(matches!(
1811 mapped[0],
1812 LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
1813 input_tokens: 10,
1814 output_tokens: 20,
1815 ..
1816 })
1817 ));
1818 assert!(matches!(
1819 mapped[1],
1820 LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)
1821 ));
1822 }
1823
1824 #[test]
1825 fn responses_stream_handles_multiple_tool_calls() {
1826 let events = vec![
1827 ResponsesStreamEvent::OutputItemAdded {
1828 output_index: 0,
1829 sequence_number: None,
1830 item: response_item_function_call("item_fn1", Some("{\"city\":\"NYC\"}")),
1831 },
1832 ResponsesStreamEvent::FunctionCallArgumentsDone {
1833 item_id: "item_fn1".into(),
1834 output_index: 0,
1835 arguments: "{\"city\":\"NYC\"}".into(),
1836 sequence_number: None,
1837 },
1838 ResponsesStreamEvent::OutputItemAdded {
1839 output_index: 1,
1840 sequence_number: None,
1841 item: response_item_function_call("item_fn2", Some("{\"city\":\"LA\"}")),
1842 },
1843 ResponsesStreamEvent::FunctionCallArgumentsDone {
1844 item_id: "item_fn2".into(),
1845 output_index: 1,
1846 arguments: "{\"city\":\"LA\"}".into(),
1847 sequence_number: None,
1848 },
1849 ResponsesStreamEvent::Completed {
1850 response: ResponseSummary::default(),
1851 },
1852 ];
1853
1854 let mapped = map_response_events(events);
1855 assert_eq!(mapped.len(), 3);
1856 assert!(matches!(
1857 mapped[0],
1858 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. })
1859 if raw_input == "{\"city\":\"NYC\"}"
1860 ));
1861 assert!(matches!(
1862 mapped[1],
1863 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. })
1864 if raw_input == "{\"city\":\"LA\"}"
1865 ));
1866 assert!(matches!(
1867 mapped[2],
1868 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
1869 ));
1870 }
1871
1872 #[test]
1873 fn responses_stream_handles_mixed_text_and_tool_calls() {
1874 let events = vec![
1875 ResponsesStreamEvent::OutputItemAdded {
1876 output_index: 0,
1877 sequence_number: None,
1878 item: response_item_message("msg_123"),
1879 },
1880 ResponsesStreamEvent::OutputTextDelta {
1881 item_id: "msg_123".into(),
1882 output_index: 0,
1883 content_index: Some(0),
1884 delta: "Let me check that".into(),
1885 },
1886 ResponsesStreamEvent::OutputItemAdded {
1887 output_index: 1,
1888 sequence_number: None,
1889 item: response_item_function_call("item_fn", Some("{\"query\":\"test\"}")),
1890 },
1891 ResponsesStreamEvent::FunctionCallArgumentsDone {
1892 item_id: "item_fn".into(),
1893 output_index: 1,
1894 arguments: "{\"query\":\"test\"}".into(),
1895 sequence_number: None,
1896 },
1897 ResponsesStreamEvent::Completed {
1898 response: ResponseSummary::default(),
1899 },
1900 ];
1901
1902 let mapped = map_response_events(events);
1903 assert!(matches!(
1904 mapped[0],
1905 LanguageModelCompletionEvent::StartMessage { .. }
1906 ));
1907 assert!(matches!(
1908 mapped[1],
1909 LanguageModelCompletionEvent::Text(ref text) if text == "Let me check that"
1910 ));
1911 assert!(matches!(
1912 mapped[2],
1913 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. })
1914 if raw_input == "{\"query\":\"test\"}"
1915 ));
1916 assert!(matches!(
1917 mapped[3],
1918 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
1919 ));
1920 }
1921
1922 #[test]
1923 fn responses_stream_handles_json_parse_error() {
1924 let events = vec![
1925 ResponsesStreamEvent::OutputItemAdded {
1926 output_index: 0,
1927 sequence_number: None,
1928 item: response_item_function_call("item_fn", Some("{invalid json")),
1929 },
1930 ResponsesStreamEvent::FunctionCallArgumentsDone {
1931 item_id: "item_fn".into(),
1932 output_index: 0,
1933 arguments: "{invalid json".into(),
1934 sequence_number: None,
1935 },
1936 ResponsesStreamEvent::Completed {
1937 response: ResponseSummary::default(),
1938 },
1939 ];
1940
1941 let mapped = map_response_events(events);
1942 assert!(matches!(
1943 mapped[0],
1944 LanguageModelCompletionEvent::ToolUseJsonParseError {
1945 ref raw_input,
1946 ..
1947 } if raw_input.as_ref() == "{invalid json"
1948 ));
1949 }
1950
1951 #[test]
1952 fn responses_stream_handles_incomplete_function_call() {
1953 let events = vec![
1954 ResponsesStreamEvent::OutputItemAdded {
1955 output_index: 0,
1956 sequence_number: None,
1957 item: response_item_function_call("item_fn", Some("{\"city\":")),
1958 },
1959 ResponsesStreamEvent::FunctionCallArgumentsDelta {
1960 item_id: "item_fn".into(),
1961 output_index: 0,
1962 delta: "\"Boston\"".into(),
1963 sequence_number: None,
1964 },
1965 ResponsesStreamEvent::Incomplete {
1966 response: ResponseSummary {
1967 status_details: Some(ResponseStatusDetails {
1968 reason: Some("max_output_tokens".into()),
1969 r#type: Some("incomplete".into()),
1970 error: None,
1971 }),
1972 output: vec![response_item_function_call(
1973 "item_fn",
1974 Some("{\"city\":\"Boston\"}"),
1975 )],
1976 ..Default::default()
1977 },
1978 },
1979 ];
1980
1981 let mapped = map_response_events(events);
1982 assert_eq!(mapped.len(), 3);
1983 // First event is the partial tool use (from FunctionCallArgumentsDelta)
1984 assert!(matches!(
1985 mapped[0],
1986 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
1987 is_input_complete: false,
1988 ..
1989 })
1990 ));
1991 // Second event is the complete tool use (from the Incomplete response output)
1992 assert!(matches!(
1993 mapped[1],
1994 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
1995 ref raw_input,
1996 is_input_complete: true,
1997 ..
1998 })
1999 if raw_input == "{\"city\":\"Boston\"}"
2000 ));
2001 assert!(matches!(
2002 mapped[2],
2003 LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)
2004 ));
2005 }
2006
2007 #[test]
2008 fn responses_stream_incomplete_does_not_duplicate_tool_calls() {
2009 let events = vec![
2010 ResponsesStreamEvent::OutputItemAdded {
2011 output_index: 0,
2012 sequence_number: None,
2013 item: response_item_function_call("item_fn", Some("{\"city\":\"Boston\"}")),
2014 },
2015 ResponsesStreamEvent::FunctionCallArgumentsDone {
2016 item_id: "item_fn".into(),
2017 output_index: 0,
2018 arguments: "{\"city\":\"Boston\"}".into(),
2019 sequence_number: None,
2020 },
2021 ResponsesStreamEvent::Incomplete {
2022 response: ResponseSummary {
2023 status_details: Some(ResponseStatusDetails {
2024 reason: Some("max_output_tokens".into()),
2025 r#type: Some("incomplete".into()),
2026 error: None,
2027 }),
2028 output: vec![response_item_function_call(
2029 "item_fn",
2030 Some("{\"city\":\"Boston\"}"),
2031 )],
2032 ..Default::default()
2033 },
2034 },
2035 ];
2036
2037 let mapped = map_response_events(events);
2038 assert_eq!(mapped.len(), 2);
2039 assert!(matches!(
2040 mapped[0],
2041 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. })
2042 if raw_input == "{\"city\":\"Boston\"}"
2043 ));
2044 assert!(matches!(
2045 mapped[1],
2046 LanguageModelCompletionEvent::Stop(StopReason::MaxTokens)
2047 ));
2048 }
2049
2050 #[test]
2051 fn responses_stream_handles_empty_tool_arguments() {
2052 // Test that tools with no arguments (empty string) are handled correctly
2053 let events = vec![
2054 ResponsesStreamEvent::OutputItemAdded {
2055 output_index: 0,
2056 sequence_number: None,
2057 item: response_item_function_call("item_fn", Some("")),
2058 },
2059 ResponsesStreamEvent::FunctionCallArgumentsDone {
2060 item_id: "item_fn".into(),
2061 output_index: 0,
2062 arguments: "".into(),
2063 sequence_number: None,
2064 },
2065 ResponsesStreamEvent::Completed {
2066 response: ResponseSummary::default(),
2067 },
2068 ];
2069
2070 let mapped = map_response_events(events);
2071 assert_eq!(mapped.len(), 2);
2072
2073 // Should produce a ToolUse event with an empty object
2074 assert!(matches!(
2075 &mapped[0],
2076 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
2077 id,
2078 name,
2079 raw_input,
2080 input,
2081 ..
2082 }) if id.to_string() == "call_123"
2083 && name.as_ref() == "get_weather"
2084 && raw_input == ""
2085 && input.is_object()
2086 && input.as_object().unwrap().is_empty()
2087 ));
2088
2089 assert!(matches!(
2090 mapped[1],
2091 LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
2092 ));
2093 }
2094
2095 #[test]
2096 fn responses_stream_emits_partial_tool_use_events() {
2097 let events = vec![
2098 ResponsesStreamEvent::OutputItemAdded {
2099 output_index: 0,
2100 sequence_number: None,
2101 item: ResponseOutputItem::FunctionCall(ResponseFunctionToolCall {
2102 id: Some("item_fn".to_string()),
2103 status: Some("in_progress".to_string()),
2104 name: Some("get_weather".to_string()),
2105 call_id: Some("call_abc".to_string()),
2106 arguments: String::new(),
2107 }),
2108 },
2109 ResponsesStreamEvent::FunctionCallArgumentsDelta {
2110 item_id: "item_fn".into(),
2111 output_index: 0,
2112 delta: "{\"city\":\"Bos".into(),
2113 sequence_number: None,
2114 },
2115 ResponsesStreamEvent::FunctionCallArgumentsDelta {
2116 item_id: "item_fn".into(),
2117 output_index: 0,
2118 delta: "ton\"}".into(),
2119 sequence_number: None,
2120 },
2121 ResponsesStreamEvent::FunctionCallArgumentsDone {
2122 item_id: "item_fn".into(),
2123 output_index: 0,
2124 arguments: "{\"city\":\"Boston\"}".into(),
2125 sequence_number: None,
2126 },
2127 ResponsesStreamEvent::Completed {
2128 response: ResponseSummary::default(),
2129 },
2130 ];
2131
2132 let mapped = map_response_events(events);
2133 // Two partial events + one complete event + Stop
2134 assert!(mapped.len() >= 3);
2135
2136 // The last complete ToolUse event should have is_input_complete: true
2137 let complete_tool_use = mapped.iter().find(|e| {
2138 matches!(
2139 e,
2140 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
2141 is_input_complete: true,
2142 ..
2143 })
2144 )
2145 });
2146 assert!(
2147 complete_tool_use.is_some(),
2148 "should have a complete tool use event"
2149 );
2150
2151 // All ToolUse events before the final one should have is_input_complete: false
2152 let tool_uses: Vec<_> = mapped
2153 .iter()
2154 .filter(|e| matches!(e, LanguageModelCompletionEvent::ToolUse(_)))
2155 .collect();
2156 assert!(
2157 tool_uses.len() >= 2,
2158 "should have at least one partial and one complete event"
2159 );
2160
2161 let last = tool_uses.last().unwrap();
2162 assert!(matches!(
2163 last,
2164 LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
2165 is_input_complete: true,
2166 ..
2167 })
2168 ));
2169 }
2170
2171 #[test]
2172 fn responses_stream_maps_reasoning_summary_deltas() {
2173 let events = vec![
2174 ResponsesStreamEvent::OutputItemAdded {
2175 output_index: 0,
2176 sequence_number: None,
2177 item: ResponseOutputItem::Reasoning(ResponseReasoningItem {
2178 id: Some("rs_123".into()),
2179 summary: vec![],
2180 }),
2181 },
2182 ResponsesStreamEvent::ReasoningSummaryPartAdded {
2183 item_id: "rs_123".into(),
2184 output_index: 0,
2185 summary_index: 0,
2186 },
2187 ResponsesStreamEvent::ReasoningSummaryTextDelta {
2188 item_id: "rs_123".into(),
2189 output_index: 0,
2190 delta: "Thinking about".into(),
2191 },
2192 ResponsesStreamEvent::ReasoningSummaryTextDelta {
2193 item_id: "rs_123".into(),
2194 output_index: 0,
2195 delta: " the answer".into(),
2196 },
2197 ResponsesStreamEvent::ReasoningSummaryTextDone {
2198 item_id: "rs_123".into(),
2199 output_index: 0,
2200 text: "Thinking about the answer".into(),
2201 },
2202 ResponsesStreamEvent::ReasoningSummaryPartDone {
2203 item_id: "rs_123".into(),
2204 output_index: 0,
2205 summary_index: 0,
2206 },
2207 ResponsesStreamEvent::ReasoningSummaryPartAdded {
2208 item_id: "rs_123".into(),
2209 output_index: 0,
2210 summary_index: 1,
2211 },
2212 ResponsesStreamEvent::ReasoningSummaryTextDelta {
2213 item_id: "rs_123".into(),
2214 output_index: 0,
2215 delta: "Second part".into(),
2216 },
2217 ResponsesStreamEvent::ReasoningSummaryTextDone {
2218 item_id: "rs_123".into(),
2219 output_index: 0,
2220 text: "Second part".into(),
2221 },
2222 ResponsesStreamEvent::ReasoningSummaryPartDone {
2223 item_id: "rs_123".into(),
2224 output_index: 0,
2225 summary_index: 1,
2226 },
2227 ResponsesStreamEvent::OutputItemDone {
2228 output_index: 0,
2229 sequence_number: None,
2230 item: ResponseOutputItem::Reasoning(ResponseReasoningItem {
2231 id: Some("rs_123".into()),
2232 summary: vec![
2233 ReasoningSummaryPart::SummaryText {
2234 text: "Thinking about the answer".into(),
2235 },
2236 ReasoningSummaryPart::SummaryText {
2237 text: "Second part".into(),
2238 },
2239 ],
2240 }),
2241 },
2242 ResponsesStreamEvent::OutputItemAdded {
2243 output_index: 1,
2244 sequence_number: None,
2245 item: response_item_message("msg_456"),
2246 },
2247 ResponsesStreamEvent::OutputTextDelta {
2248 item_id: "msg_456".into(),
2249 output_index: 1,
2250 content_index: Some(0),
2251 delta: "The answer is 42".into(),
2252 },
2253 ResponsesStreamEvent::Completed {
2254 response: ResponseSummary::default(),
2255 },
2256 ];
2257
2258 let mapped = map_response_events(events);
2259
2260 let thinking_events: Vec<_> = mapped
2261 .iter()
2262 .filter(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. }))
2263 .collect();
2264 assert_eq!(
2265 thinking_events.len(),
2266 4,
2267 "expected 4 thinking events (2 deltas + separator + second delta), got {:?}",
2268 thinking_events,
2269 );
2270
2271 assert!(matches!(
2272 &thinking_events[0],
2273 LanguageModelCompletionEvent::Thinking { text, .. } if text == "Thinking about"
2274 ));
2275 assert!(matches!(
2276 &thinking_events[1],
2277 LanguageModelCompletionEvent::Thinking { text, .. } if text == " the answer"
2278 ));
2279 assert!(
2280 matches!(
2281 &thinking_events[2],
2282 LanguageModelCompletionEvent::Thinking { text, .. } if text == "\n\n"
2283 ),
2284 "expected separator between summary parts"
2285 );
2286 assert!(matches!(
2287 &thinking_events[3],
2288 LanguageModelCompletionEvent::Thinking { text, .. } if text == "Second part"
2289 ));
2290
2291 assert!(mapped.iter().any(|e| matches!(
2292 e,
2293 LanguageModelCompletionEvent::Text(t) if t == "The answer is 42"
2294 )));
2295 }
2296
2297 #[test]
2298 fn responses_stream_maps_reasoning_from_done_only() {
2299 let events = vec![
2300 ResponsesStreamEvent::OutputItemAdded {
2301 output_index: 0,
2302 sequence_number: None,
2303 item: ResponseOutputItem::Reasoning(ResponseReasoningItem {
2304 id: Some("rs_789".into()),
2305 summary: vec![],
2306 }),
2307 },
2308 ResponsesStreamEvent::OutputItemDone {
2309 output_index: 0,
2310 sequence_number: None,
2311 item: ResponseOutputItem::Reasoning(ResponseReasoningItem {
2312 id: Some("rs_789".into()),
2313 summary: vec![ReasoningSummaryPart::SummaryText {
2314 text: "Summary without deltas".into(),
2315 }],
2316 }),
2317 },
2318 ResponsesStreamEvent::Completed {
2319 response: ResponseSummary::default(),
2320 },
2321 ];
2322
2323 let mapped = map_response_events(events);
2324
2325 assert!(
2326 !mapped
2327 .iter()
2328 .any(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. })),
2329 "OutputItemDone reasoning should not produce Thinking events (no delta/done text events)"
2330 );
2331 }
2332}