language_models: Handle usage-only events with empty choices in OpenRouter (#50603)

Umesh Yadav created

Closes #50569

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Previously, OpenRouter responses containing only usage data (without any
choices) would cause an error. Now the mapper properly emits usage
updates
for these events without failing.

Release Notes:

- Fixed an error when OpenRouter returns a usage-only event with empty
choices.

Change summary

crates/language_models/src/provider/open_router.rs | 54 +++++++++++----
1 file changed, 39 insertions(+), 15 deletions(-)

Detailed changes

crates/language_models/src/provider/open_router.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use collections::HashMap;
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task};
@@ -591,14 +591,21 @@ impl OpenRouterEventMapper {
         &mut self,
         event: ResponseStreamEvent,
     ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
+        let mut events = Vec::new();
+
+        if let Some(usage) = event.usage {
+            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: usage.prompt_tokens,
+                output_tokens: usage.completion_tokens,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })));
+        }
+
         let Some(choice) = event.choices.first() else {
-            return vec![Err(LanguageModelCompletionError::from(anyhow!(
-                "Response contained no choices"
-            )))];
+            return events;
         };
 
-        let mut events = Vec::new();
-
         if let Some(details) = choice.delta.reasoning_details.clone() {
             // Emit reasoning_details immediately
             events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails(
@@ -646,15 +653,6 @@ impl OpenRouterEventMapper {
             }
         }
 
-        if let Some(usage) = event.usage {
-            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
-                input_tokens: usage.prompt_tokens,
-                output_tokens: usage.completion_tokens,
-                cache_creation_input_tokens: 0,
-                cache_read_input_tokens: 0,
-            })));
-        }
-
         match choice.finish_reason.as_deref() {
             Some("stop") => {
                 // Don't emit reasoning_details here - already emitted immediately when captured
@@ -1055,6 +1053,32 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_usage_only_chunk_with_empty_choices_does_not_error() {
+        let mut mapper = OpenRouterEventMapper::new();
+
+        let events = mapper.map_event(ResponseStreamEvent {
+            id: Some("response_123".into()),
+            created: 1234567890,
+            model: "google/gemini-3-flash-preview".into(),
+            choices: Vec::new(),
+            usage: Some(open_router::Usage {
+                prompt_tokens: 12,
+                completion_tokens: 7,
+                total_tokens: 19,
+            }),
+        });
+
+        assert_eq!(events.len(), 1);
+        match events.into_iter().next().unwrap() {
+            Ok(LanguageModelCompletionEvent::UsageUpdate(usage)) => {
+                assert_eq!(usage.input_tokens, 12);
+                assert_eq!(usage.output_tokens, 7);
+            }
+            other => panic!("Expected usage update event, got: {other:?}"),
+        }
+    }
+
     #[gpui::test]
     async fn test_agent_prevents_empty_reasoning_details_overwrite() {
         // This test verifies that the agent layer prevents empty reasoning_details