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