1use anyhow::{Result, anyhow};
2use collections::HashMap;
3use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
4use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task};
5use http_client::HttpClient;
6use language_model::{
7 ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
8 LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
9 LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
10 LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
11 LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
12 StopReason, TokenUsage, env_var,
13};
14use open_router::{
15 Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models,
16};
17use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore};
18use std::pin::Pin;
19use std::str::FromStr as _;
20use std::sync::{Arc, LazyLock};
21use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
22use ui_input::InputField;
23use util::ResultExt;
24
25const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter");
26const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter");
27
28const API_KEY_ENV_VAR_NAME: &str = "OPENROUTER_API_KEY";
29static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
30
31#[derive(Default, Clone, Debug, PartialEq)]
32pub struct OpenRouterSettings {
33 pub api_url: String,
34 pub available_models: Vec<AvailableModel>,
35}
36
37pub struct OpenRouterLanguageModelProvider {
38 http_client: Arc<dyn HttpClient>,
39 state: Entity<State>,
40}
41
42pub struct State {
43 api_key_state: ApiKeyState,
44 http_client: Arc<dyn HttpClient>,
45 available_models: Vec<open_router::Model>,
46 fetch_models_task: Option<Task<Result<(), LanguageModelCompletionError>>>,
47}
48
49impl State {
50 fn is_authenticated(&self) -> bool {
51 self.api_key_state.has_key()
52 }
53
54 fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
55 let api_url = OpenRouterLanguageModelProvider::api_url(cx);
56 self.api_key_state
57 .store(api_url, api_key, |this| &mut this.api_key_state, cx)
58 }
59
60 fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
61 let api_url = OpenRouterLanguageModelProvider::api_url(cx);
62 let task = self
63 .api_key_state
64 .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
65
66 cx.spawn(async move |this, cx| {
67 let result = task.await;
68 this.update(cx, |this, cx| this.restart_fetch_models_task(cx))
69 .ok();
70 result
71 })
72 }
73
74 fn fetch_models(
75 &mut self,
76 cx: &mut Context<Self>,
77 ) -> Task<Result<(), LanguageModelCompletionError>> {
78 let http_client = self.http_client.clone();
79 let api_url = OpenRouterLanguageModelProvider::api_url(cx);
80 let Some(api_key) = self.api_key_state.key(&api_url) else {
81 return Task::ready(Err(LanguageModelCompletionError::NoApiKey {
82 provider: PROVIDER_NAME,
83 }));
84 };
85 cx.spawn(async move |this, cx| {
86 let models = list_models(http_client.as_ref(), &api_url, &api_key)
87 .await
88 .map_err(|e| {
89 LanguageModelCompletionError::Other(anyhow::anyhow!(
90 "OpenRouter error: {:?}",
91 e
92 ))
93 })?;
94
95 this.update(cx, |this, cx| {
96 this.available_models = models;
97 cx.notify();
98 })
99 .map_err(|e| LanguageModelCompletionError::Other(e))?;
100
101 Ok(())
102 })
103 }
104
105 fn restart_fetch_models_task(&mut self, cx: &mut Context<Self>) {
106 if self.is_authenticated() {
107 let task = self.fetch_models(cx);
108 self.fetch_models_task.replace(task);
109 } else {
110 self.available_models = Vec::new();
111 }
112 }
113}
114
115impl OpenRouterLanguageModelProvider {
116 pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
117 let state = cx.new(|cx| {
118 cx.observe_global::<SettingsStore>({
119 let mut last_settings = OpenRouterLanguageModelProvider::settings(cx).clone();
120 move |this: &mut State, cx| {
121 let current_settings = OpenRouterLanguageModelProvider::settings(cx);
122 let settings_changed = current_settings != &last_settings;
123 if settings_changed {
124 last_settings = current_settings.clone();
125 this.authenticate(cx).detach();
126 cx.notify();
127 }
128 }
129 })
130 .detach();
131 State {
132 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
133 http_client: http_client.clone(),
134 available_models: Vec::new(),
135 fetch_models_task: None,
136 }
137 });
138
139 Self { http_client, state }
140 }
141
142 fn settings(cx: &App) -> &OpenRouterSettings {
143 &crate::AllLanguageModelSettings::get_global(cx).open_router
144 }
145
146 fn api_url(cx: &App) -> SharedString {
147 let api_url = &Self::settings(cx).api_url;
148 if api_url.is_empty() {
149 OPEN_ROUTER_API_URL.into()
150 } else {
151 SharedString::new(api_url.as_str())
152 }
153 }
154
155 fn create_language_model(&self, model: open_router::Model) -> Arc<dyn LanguageModel> {
156 Arc::new(OpenRouterLanguageModel {
157 id: LanguageModelId::from(model.id().to_string()),
158 model,
159 state: self.state.clone(),
160 http_client: self.http_client.clone(),
161 request_limiter: RateLimiter::new(4),
162 })
163 }
164}
165
166impl LanguageModelProviderState for OpenRouterLanguageModelProvider {
167 type ObservableEntity = State;
168
169 fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
170 Some(self.state.clone())
171 }
172}
173
174impl LanguageModelProvider for OpenRouterLanguageModelProvider {
175 fn id(&self) -> LanguageModelProviderId {
176 PROVIDER_ID
177 }
178
179 fn name(&self) -> LanguageModelProviderName {
180 PROVIDER_NAME
181 }
182
183 fn icon(&self) -> IconOrSvg {
184 IconOrSvg::Icon(IconName::AiOpenRouter)
185 }
186
187 fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
188 Some(self.create_language_model(open_router::Model::default()))
189 }
190
191 fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
192 Some(self.create_language_model(open_router::Model::default_fast()))
193 }
194
195 fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
196 let mut models_from_api = self.state.read(cx).available_models.clone();
197 let mut settings_models = Vec::new();
198
199 for model in &Self::settings(cx).available_models {
200 settings_models.push(open_router::Model {
201 name: model.name.clone(),
202 display_name: model.display_name.clone(),
203 max_tokens: model.max_tokens,
204 supports_tools: model.supports_tools,
205 supports_images: model.supports_images,
206 mode: model.mode.unwrap_or_default(),
207 provider: model.provider.clone(),
208 });
209 }
210
211 for settings_model in &settings_models {
212 if let Some(pos) = models_from_api
213 .iter()
214 .position(|m| m.name == settings_model.name)
215 {
216 models_from_api[pos] = settings_model.clone();
217 } else {
218 models_from_api.push(settings_model.clone());
219 }
220 }
221
222 models_from_api
223 .into_iter()
224 .map(|model| self.create_language_model(model))
225 .collect()
226 }
227
228 fn is_authenticated(&self, cx: &App) -> bool {
229 self.state.read(cx).is_authenticated()
230 }
231
232 fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
233 self.state.update(cx, |state, cx| state.authenticate(cx))
234 }
235
236 fn configuration_view(
237 &self,
238 _target_agent: language_model::ConfigurationViewTargetAgent,
239 window: &mut Window,
240 cx: &mut App,
241 ) -> AnyView {
242 cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
243 .into()
244 }
245
246 fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
247 self.state
248 .update(cx, |state, cx| state.set_api_key(None, cx))
249 }
250}
251
252pub struct OpenRouterLanguageModel {
253 id: LanguageModelId,
254 model: open_router::Model,
255 state: Entity<State>,
256 http_client: Arc<dyn HttpClient>,
257 request_limiter: RateLimiter,
258}
259
260impl OpenRouterLanguageModel {
261 fn stream_completion(
262 &self,
263 request: open_router::Request,
264 cx: &AsyncApp,
265 ) -> BoxFuture<
266 'static,
267 Result<
268 futures::stream::BoxStream<
269 'static,
270 Result<ResponseStreamEvent, open_router::OpenRouterError>,
271 >,
272 LanguageModelCompletionError,
273 >,
274 > {
275 let http_client = self.http_client.clone();
276 let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
277 let api_url = OpenRouterLanguageModelProvider::api_url(cx);
278 (state.api_key_state.key(&api_url), api_url)
279 });
280
281 async move {
282 let Some(api_key) = api_key else {
283 return Err(LanguageModelCompletionError::NoApiKey {
284 provider: PROVIDER_NAME,
285 });
286 };
287 let request =
288 open_router::stream_completion(http_client.as_ref(), &api_url, &api_key, request);
289 request.await.map_err(Into::into)
290 }
291 .boxed()
292 }
293}
294
295impl LanguageModel for OpenRouterLanguageModel {
296 fn id(&self) -> LanguageModelId {
297 self.id.clone()
298 }
299
300 fn name(&self) -> LanguageModelName {
301 LanguageModelName::from(self.model.display_name().to_string())
302 }
303
304 fn provider_id(&self) -> LanguageModelProviderId {
305 PROVIDER_ID
306 }
307
308 fn provider_name(&self) -> LanguageModelProviderName {
309 PROVIDER_NAME
310 }
311
312 fn supports_tools(&self) -> bool {
313 self.model.supports_tool_calls()
314 }
315
316 fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
317 let model_id = self.model.id().trim().to_lowercase();
318 if model_id.contains("gemini") || model_id.contains("grok") {
319 LanguageModelToolSchemaFormat::JsonSchemaSubset
320 } else {
321 LanguageModelToolSchemaFormat::JsonSchema
322 }
323 }
324
325 fn telemetry_id(&self) -> String {
326 format!("openrouter/{}", self.model.id())
327 }
328
329 fn max_token_count(&self) -> u64 {
330 self.model.max_token_count()
331 }
332
333 fn max_output_tokens(&self) -> Option<u64> {
334 self.model.max_output_tokens()
335 }
336
337 fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
338 match choice {
339 LanguageModelToolChoice::Auto => true,
340 LanguageModelToolChoice::Any => true,
341 LanguageModelToolChoice::None => true,
342 }
343 }
344
345 fn supports_images(&self) -> bool {
346 self.model.supports_images.unwrap_or(false)
347 }
348
349 fn count_tokens(
350 &self,
351 request: LanguageModelRequest,
352 cx: &App,
353 ) -> BoxFuture<'static, Result<u64>> {
354 count_open_router_tokens(request, self.model.clone(), cx)
355 }
356
357 fn stream_completion(
358 &self,
359 request: LanguageModelRequest,
360 cx: &AsyncApp,
361 ) -> BoxFuture<
362 'static,
363 Result<
364 futures::stream::BoxStream<
365 'static,
366 Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
367 >,
368 LanguageModelCompletionError,
369 >,
370 > {
371 let bypass_rate_limit = request.bypass_rate_limit;
372 let openrouter_request = into_open_router(request, &self.model, self.max_output_tokens());
373 let request = self.stream_completion(openrouter_request, cx);
374 let future = self.request_limiter.stream_with_bypass(
375 async move {
376 let response = request.await?;
377 Ok(OpenRouterEventMapper::new().map_stream(response))
378 },
379 bypass_rate_limit,
380 );
381 async move { Ok(future.await?.boxed()) }.boxed()
382 }
383}
384
385pub fn into_open_router(
386 request: LanguageModelRequest,
387 model: &Model,
388 max_output_tokens: Option<u64>,
389) -> open_router::Request {
390 // Anthropic models via OpenRouter don't accept reasoning_details being echoed back
391 // in requests - it's an output-only field for them. However, Gemini models require
392 // the thought signatures to be echoed back for proper reasoning chain continuity.
393 // Note: OpenRouter's model API provides an `architecture.tokenizer` field (e.g. "Claude",
394 // "Gemini") which could replace this ID prefix check, but since this is the only place
395 // we need this distinction, we're just using this less invasive check instead.
396 // If we ever have a more formal distionction between the models in the future,
397 // we should revise this to use that instead.
398 let is_anthropic_model = model.id().starts_with("anthropic/");
399
400 let mut messages = Vec::new();
401 for message in request.messages {
402 let reasoning_details_for_message = if is_anthropic_model {
403 None
404 } else {
405 message.reasoning_details.clone()
406 };
407
408 for content in message.content {
409 match content {
410 MessageContent::Text(text) => add_message_content_part(
411 open_router::MessagePart::Text { text },
412 message.role,
413 &mut messages,
414 reasoning_details_for_message.clone(),
415 ),
416 MessageContent::Thinking { .. } => {}
417 MessageContent::RedactedThinking(_) => {}
418 MessageContent::Image(image) => {
419 add_message_content_part(
420 open_router::MessagePart::Image {
421 image_url: image.to_base64_url(),
422 },
423 message.role,
424 &mut messages,
425 reasoning_details_for_message.clone(),
426 );
427 }
428 MessageContent::ToolUse(tool_use) => {
429 let tool_call = open_router::ToolCall {
430 id: tool_use.id.to_string(),
431 content: open_router::ToolCallContent::Function {
432 function: open_router::FunctionContent {
433 name: tool_use.name.to_string(),
434 arguments: serde_json::to_string(&tool_use.input)
435 .unwrap_or_default(),
436 thought_signature: tool_use.thought_signature.clone(),
437 },
438 },
439 };
440
441 if let Some(open_router::RequestMessage::Assistant { tool_calls, .. }) =
442 messages.last_mut()
443 {
444 tool_calls.push(tool_call);
445 } else {
446 messages.push(open_router::RequestMessage::Assistant {
447 content: None,
448 tool_calls: vec![tool_call],
449 reasoning_details: reasoning_details_for_message.clone(),
450 });
451 }
452 }
453 MessageContent::ToolResult(tool_result) => {
454 let content = match &tool_result.content {
455 LanguageModelToolResultContent::Text(text) => {
456 vec![open_router::MessagePart::Text {
457 text: text.to_string(),
458 }]
459 }
460 LanguageModelToolResultContent::Image(image) => {
461 vec![open_router::MessagePart::Image {
462 image_url: image.to_base64_url(),
463 }]
464 }
465 };
466
467 messages.push(open_router::RequestMessage::Tool {
468 content: content.into(),
469 tool_call_id: tool_result.tool_use_id.to_string(),
470 });
471 }
472 }
473 }
474 }
475
476 open_router::Request {
477 model: model.id().into(),
478 messages,
479 stream: true,
480 stop: request.stop,
481 temperature: request.temperature.unwrap_or(0.4),
482 max_tokens: max_output_tokens,
483 parallel_tool_calls: if model.supports_parallel_tool_calls() && !request.tools.is_empty() {
484 Some(false)
485 } else {
486 None
487 },
488 usage: open_router::RequestUsage { include: true },
489 reasoning: if request.thinking_allowed
490 && let OpenRouterModelMode::Thinking { budget_tokens } = model.mode
491 {
492 Some(open_router::Reasoning {
493 effort: None,
494 max_tokens: budget_tokens,
495 exclude: Some(false),
496 enabled: Some(true),
497 })
498 } else {
499 None
500 },
501 tools: request
502 .tools
503 .into_iter()
504 .map(|tool| open_router::ToolDefinition::Function {
505 function: open_router::FunctionDefinition {
506 name: tool.name,
507 description: Some(tool.description),
508 parameters: Some(tool.input_schema),
509 },
510 })
511 .collect(),
512 tool_choice: request.tool_choice.map(|choice| match choice {
513 LanguageModelToolChoice::Auto => open_router::ToolChoice::Auto,
514 LanguageModelToolChoice::Any => open_router::ToolChoice::Required,
515 LanguageModelToolChoice::None => open_router::ToolChoice::None,
516 }),
517 provider: model.provider.clone(),
518 }
519}
520
521fn add_message_content_part(
522 new_part: open_router::MessagePart,
523 role: Role,
524 messages: &mut Vec<open_router::RequestMessage>,
525 reasoning_details: Option<serde_json::Value>,
526) {
527 match (role, messages.last_mut()) {
528 (Role::User, Some(open_router::RequestMessage::User { content }))
529 | (Role::System, Some(open_router::RequestMessage::System { content })) => {
530 content.push_part(new_part);
531 }
532 (
533 Role::Assistant,
534 Some(open_router::RequestMessage::Assistant {
535 content: Some(content),
536 ..
537 }),
538 ) => {
539 content.push_part(new_part);
540 }
541 _ => {
542 messages.push(match role {
543 Role::User => open_router::RequestMessage::User {
544 content: open_router::MessageContent::from(vec![new_part]),
545 },
546 Role::Assistant => open_router::RequestMessage::Assistant {
547 content: Some(open_router::MessageContent::from(vec![new_part])),
548 tool_calls: Vec::new(),
549 reasoning_details,
550 },
551 Role::System => open_router::RequestMessage::System {
552 content: open_router::MessageContent::from(vec![new_part]),
553 },
554 });
555 }
556 }
557}
558
559pub struct OpenRouterEventMapper {
560 tool_calls_by_index: HashMap<usize, RawToolCall>,
561 reasoning_details: Option<serde_json::Value>,
562}
563
564impl OpenRouterEventMapper {
565 pub fn new() -> Self {
566 Self {
567 tool_calls_by_index: HashMap::default(),
568 reasoning_details: None,
569 }
570 }
571
572 pub fn map_stream(
573 mut self,
574 events: Pin<
575 Box<
576 dyn Send + Stream<Item = Result<ResponseStreamEvent, open_router::OpenRouterError>>,
577 >,
578 >,
579 ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
580 {
581 events.flat_map(move |event| {
582 futures::stream::iter(match event {
583 Ok(event) => self.map_event(event),
584 Err(error) => vec![Err(error.into())],
585 })
586 })
587 }
588
589 pub fn map_event(
590 &mut self,
591 event: ResponseStreamEvent,
592 ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
593 let Some(choice) = event.choices.first() else {
594 return vec![Err(LanguageModelCompletionError::from(anyhow!(
595 "Response contained no choices"
596 )))];
597 };
598
599 let mut events = Vec::new();
600
601 if let Some(details) = choice.delta.reasoning_details.clone() {
602 // Emit reasoning_details immediately
603 events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails(
604 details.clone(),
605 )));
606 self.reasoning_details = Some(details);
607 }
608
609 if let Some(reasoning) = choice.delta.reasoning.clone() {
610 events.push(Ok(LanguageModelCompletionEvent::Thinking {
611 text: reasoning,
612 signature: None,
613 }));
614 }
615
616 if let Some(content) = choice.delta.content.clone() {
617 // OpenRouter send empty content string with the reasoning content
618 // This is a workaround for the OpenRouter API bug
619 if !content.is_empty() {
620 events.push(Ok(LanguageModelCompletionEvent::Text(content)));
621 }
622 }
623
624 if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
625 for tool_call in tool_calls {
626 let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
627
628 if let Some(tool_id) = tool_call.id.clone() {
629 entry.id = tool_id;
630 }
631
632 if let Some(function) = tool_call.function.as_ref() {
633 if let Some(name) = function.name.clone() {
634 entry.name = name;
635 }
636
637 if let Some(arguments) = function.arguments.clone() {
638 entry.arguments.push_str(&arguments);
639 }
640
641 if let Some(signature) = function.thought_signature.clone() {
642 entry.thought_signature = Some(signature);
643 }
644 }
645 }
646 }
647
648 if let Some(usage) = event.usage {
649 events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
650 input_tokens: usage.prompt_tokens,
651 output_tokens: usage.completion_tokens,
652 cache_creation_input_tokens: 0,
653 cache_read_input_tokens: 0,
654 })));
655 }
656
657 match choice.finish_reason.as_deref() {
658 Some("stop") => {
659 // Don't emit reasoning_details here - already emitted immediately when captured
660 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
661 }
662 Some("tool_calls") => {
663 events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
664 match serde_json::Value::from_str(&tool_call.arguments) {
665 Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
666 LanguageModelToolUse {
667 id: tool_call.id.clone().into(),
668 name: tool_call.name.as_str().into(),
669 is_input_complete: true,
670 input,
671 raw_input: tool_call.arguments.clone(),
672 thought_signature: tool_call.thought_signature.clone(),
673 },
674 )),
675 Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
676 id: tool_call.id.clone().into(),
677 tool_name: tool_call.name.as_str().into(),
678 raw_input: tool_call.arguments.clone().into(),
679 json_parse_error: error.to_string(),
680 }),
681 }
682 }));
683
684 // Don't emit reasoning_details here - already emitted immediately when captured
685 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
686 }
687 Some(stop_reason) => {
688 log::error!("Unexpected OpenRouter stop_reason: {stop_reason:?}",);
689 // Don't emit reasoning_details here - already emitted immediately when captured
690 events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
691 }
692 None => {}
693 }
694
695 events
696 }
697}
698
699#[derive(Default)]
700struct RawToolCall {
701 id: String,
702 name: String,
703 arguments: String,
704 thought_signature: Option<String>,
705}
706
707pub fn count_open_router_tokens(
708 request: LanguageModelRequest,
709 _model: open_router::Model,
710 cx: &App,
711) -> BoxFuture<'static, Result<u64>> {
712 cx.background_spawn(async move {
713 let messages = request
714 .messages
715 .into_iter()
716 .map(|message| tiktoken_rs::ChatCompletionRequestMessage {
717 role: match message.role {
718 Role::User => "user".into(),
719 Role::Assistant => "assistant".into(),
720 Role::System => "system".into(),
721 },
722 content: Some(message.string_contents()),
723 name: None,
724 function_call: None,
725 })
726 .collect::<Vec<_>>();
727
728 tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages).map(|tokens| tokens as u64)
729 })
730 .boxed()
731}
732
733struct ConfigurationView {
734 api_key_editor: Entity<InputField>,
735 state: Entity<State>,
736 load_credentials_task: Option<Task<()>>,
737}
738
739impl ConfigurationView {
740 fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
741 let api_key_editor = cx.new(|cx| {
742 InputField::new(
743 window,
744 cx,
745 "sk_or_000000000000000000000000000000000000000000000000",
746 )
747 });
748
749 cx.observe(&state, |_, _, cx| {
750 cx.notify();
751 })
752 .detach();
753
754 let load_credentials_task = Some(cx.spawn_in(window, {
755 let state = state.clone();
756 async move |this, cx| {
757 if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) {
758 let _ = task.await;
759 }
760
761 this.update(cx, |this, cx| {
762 this.load_credentials_task = None;
763 cx.notify();
764 })
765 .log_err();
766 }
767 }));
768
769 Self {
770 api_key_editor,
771 state,
772 load_credentials_task,
773 }
774 }
775
776 fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
777 let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string();
778 if api_key.is_empty() {
779 return;
780 }
781
782 // url changes can cause the editor to be displayed again
783 self.api_key_editor
784 .update(cx, |editor, cx| editor.set_text("", window, cx));
785
786 let state = self.state.clone();
787 cx.spawn_in(window, async move |_, cx| {
788 state
789 .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))
790 .await
791 })
792 .detach_and_log_err(cx);
793 }
794
795 fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
796 self.api_key_editor
797 .update(cx, |editor, cx| editor.set_text("", window, cx));
798
799 let state = self.state.clone();
800 cx.spawn_in(window, async move |_, cx| {
801 state
802 .update(cx, |state, cx| state.set_api_key(None, cx))
803 .await
804 })
805 .detach_and_log_err(cx);
806 }
807
808 fn should_render_editor(&self, cx: &mut Context<Self>) -> bool {
809 !self.state.read(cx).is_authenticated()
810 }
811}
812
813impl Render for ConfigurationView {
814 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
815 let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
816 let configured_card_label = if env_var_set {
817 format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable")
818 } else {
819 let api_url = OpenRouterLanguageModelProvider::api_url(cx);
820 if api_url == OPEN_ROUTER_API_URL {
821 "API key configured".to_string()
822 } else {
823 format!("API key configured for {}", api_url)
824 }
825 };
826
827 if self.load_credentials_task.is_some() {
828 div()
829 .child(Label::new("Loading credentials..."))
830 .into_any_element()
831 } else if self.should_render_editor(cx) {
832 v_flex()
833 .size_full()
834 .on_action(cx.listener(Self::save_api_key))
835 .child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:"))
836 .child(
837 List::new()
838 .child(
839 ListBulletItem::new("")
840 .child(Label::new("Create an API key by visiting"))
841 .child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys"))
842 )
843 .child(ListBulletItem::new("Ensure your OpenRouter account has credits")
844 )
845 .child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
846 ),
847 )
848 .child(self.api_key_editor.clone())
849 .child(
850 Label::new(
851 format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
852 )
853 .size(LabelSize::Small).color(Color::Muted),
854 )
855 .into_any_element()
856 } else {
857 ConfiguredApiCard::new(configured_card_label)
858 .disabled(env_var_set)
859 .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
860 .when(env_var_set, |this| {
861 this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))
862 })
863 .into_any_element()
864 }
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 use open_router::{ChoiceDelta, FunctionChunk, ResponseMessageDelta, ToolCallChunk};
873
874 #[gpui::test]
875 async fn test_reasoning_details_preservation_with_tool_calls() {
876 // This test verifies that reasoning_details are properly captured and preserved
877 // when a model uses tool calling with reasoning/thinking tokens.
878 //
879 // The key regression this prevents:
880 // - OpenRouter sends multiple reasoning_details updates during streaming
881 // - First with actual content (encrypted reasoning data)
882 // - Then with empty array on completion
883 // - We must NOT overwrite the real data with the empty array
884
885 let mut mapper = OpenRouterEventMapper::new();
886
887 // Simulate the streaming events as they come from OpenRouter/Gemini
888 let events = vec![
889 // Event 1: Initial reasoning details with text
890 ResponseStreamEvent {
891 id: Some("response_123".into()),
892 created: 1234567890,
893 model: "google/gemini-3-pro-preview".into(),
894 choices: vec![ChoiceDelta {
895 index: 0,
896 delta: ResponseMessageDelta {
897 role: None,
898 content: None,
899 reasoning: None,
900 tool_calls: None,
901 reasoning_details: Some(serde_json::json!([
902 {
903 "type": "reasoning.text",
904 "text": "Let me analyze this request...",
905 "format": "google-gemini-v1",
906 "index": 0
907 }
908 ])),
909 },
910 finish_reason: None,
911 }],
912 usage: None,
913 },
914 // Event 2: More reasoning details
915 ResponseStreamEvent {
916 id: Some("response_123".into()),
917 created: 1234567890,
918 model: "google/gemini-3-pro-preview".into(),
919 choices: vec![ChoiceDelta {
920 index: 0,
921 delta: ResponseMessageDelta {
922 role: None,
923 content: None,
924 reasoning: None,
925 tool_calls: None,
926 reasoning_details: Some(serde_json::json!([
927 {
928 "type": "reasoning.encrypted",
929 "data": "EtgDCtUDAdHtim9OF5jm4aeZSBAtl/randomized123",
930 "format": "google-gemini-v1",
931 "index": 0,
932 "id": "tool_call_abc123"
933 }
934 ])),
935 },
936 finish_reason: None,
937 }],
938 usage: None,
939 },
940 // Event 3: Tool call starts
941 ResponseStreamEvent {
942 id: Some("response_123".into()),
943 created: 1234567890,
944 model: "google/gemini-3-pro-preview".into(),
945 choices: vec![ChoiceDelta {
946 index: 0,
947 delta: ResponseMessageDelta {
948 role: None,
949 content: None,
950 reasoning: None,
951 tool_calls: Some(vec![ToolCallChunk {
952 index: 0,
953 id: Some("tool_call_abc123".into()),
954 function: Some(FunctionChunk {
955 name: Some("list_directory".into()),
956 arguments: Some("{\"path\":\"test\"}".into()),
957 thought_signature: Some("sha256:test_signature_xyz789".into()),
958 }),
959 }]),
960 reasoning_details: None,
961 },
962 finish_reason: None,
963 }],
964 usage: None,
965 },
966 // Event 4: Empty reasoning_details on tool_calls finish
967 // This is the critical event - we must not overwrite with this empty array!
968 ResponseStreamEvent {
969 id: Some("response_123".into()),
970 created: 1234567890,
971 model: "google/gemini-3-pro-preview".into(),
972 choices: vec![ChoiceDelta {
973 index: 0,
974 delta: ResponseMessageDelta {
975 role: None,
976 content: None,
977 reasoning: None,
978 tool_calls: None,
979 reasoning_details: Some(serde_json::json!([])),
980 },
981 finish_reason: Some("tool_calls".into()),
982 }],
983 usage: None,
984 },
985 ];
986
987 // Process all events
988 let mut collected_events = Vec::new();
989 for event in events {
990 let mapped = mapper.map_event(event);
991 collected_events.extend(mapped);
992 }
993
994 // Verify we got the expected events
995 let mut has_tool_use = false;
996 let mut reasoning_details_events = Vec::new();
997 let mut thought_signature_value = None;
998
999 for event_result in collected_events {
1000 match event_result {
1001 Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => {
1002 has_tool_use = true;
1003 assert_eq!(tool_use.id.to_string(), "tool_call_abc123");
1004 assert_eq!(tool_use.name.as_ref(), "list_directory");
1005 thought_signature_value = tool_use.thought_signature.clone();
1006 }
1007 Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => {
1008 reasoning_details_events.push(details);
1009 }
1010 _ => {}
1011 }
1012 }
1013
1014 // Assertions
1015 assert!(has_tool_use, "Should have emitted ToolUse event");
1016 assert!(
1017 !reasoning_details_events.is_empty(),
1018 "Should have emitted ReasoningDetails events"
1019 );
1020
1021 // We should have received multiple reasoning_details events (text, encrypted, empty)
1022 // The agent layer is responsible for keeping only the first non-empty one
1023 assert!(
1024 reasoning_details_events.len() >= 2,
1025 "Should have multiple reasoning_details events from streaming"
1026 );
1027
1028 // Verify at least one contains the encrypted data
1029 let has_encrypted = reasoning_details_events.iter().any(|details| {
1030 if let serde_json::Value::Array(arr) = details {
1031 arr.iter().any(|item| {
1032 item["type"] == "reasoning.encrypted"
1033 && item["data"]
1034 .as_str()
1035 .map_or(false, |s| s.contains("EtgDCtUDAdHtim9OF5jm4aeZSBAtl"))
1036 })
1037 } else {
1038 false
1039 }
1040 });
1041 assert!(
1042 has_encrypted,
1043 "Should have at least one reasoning_details with encrypted data"
1044 );
1045
1046 // Verify thought_signature was captured
1047 assert!(
1048 thought_signature_value.is_some(),
1049 "Tool use should have thought_signature"
1050 );
1051 assert_eq!(
1052 thought_signature_value.unwrap(),
1053 "sha256:test_signature_xyz789"
1054 );
1055 }
1056
1057 #[gpui::test]
1058 async fn test_agent_prevents_empty_reasoning_details_overwrite() {
1059 // This test verifies that the agent layer prevents empty reasoning_details
1060 // from overwriting non-empty ones, even though the mapper emits all events.
1061
1062 // Simulate what the agent does when it receives multiple ReasoningDetails events
1063 let mut agent_reasoning_details: Option<serde_json::Value> = None;
1064
1065 let events = vec![
1066 // First event: non-empty reasoning_details
1067 serde_json::json!([
1068 {
1069 "type": "reasoning.encrypted",
1070 "data": "real_data_here",
1071 "format": "google-gemini-v1"
1072 }
1073 ]),
1074 // Second event: empty array (should not overwrite)
1075 serde_json::json!([]),
1076 ];
1077
1078 for details in events {
1079 // This mimics the agent's logic: only store if we don't already have it
1080 if agent_reasoning_details.is_none() {
1081 agent_reasoning_details = Some(details);
1082 }
1083 }
1084
1085 // Verify the agent kept the first non-empty reasoning_details
1086 assert!(agent_reasoning_details.is_some());
1087 let final_details = agent_reasoning_details.unwrap();
1088 if let serde_json::Value::Array(arr) = &final_details {
1089 assert!(
1090 !arr.is_empty(),
1091 "Agent should have kept the non-empty reasoning_details"
1092 );
1093 assert_eq!(arr[0]["data"], "real_data_here");
1094 } else {
1095 panic!("Expected array");
1096 }
1097 }
1098}