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

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.
This commit is contained in:
Umesh Yadav authored and GitHub committed 2026-03-03 13:58:47 +00:00
1 parent 03942eb249
commit 2c08098390
1 file changed
+40 -16
@@ -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 Some(choice) = event.choices.first() else {
return vec![Err(LanguageModelCompletionError::from(anyhow!(
"Response contained no choices"
)))];
};
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 events;
};
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