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