Skip to main content

vtcode_llm/providers/openrouter/provider/
provider_impl.rs

1use super::super::OpenRouterProvider;
2use crate::provider::{
3    LLMError, LLMNormalizedStream, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent,
4    NormalizedStreamEvent, ReasoningSource,
5};
6use crate::providers::error_handling::{format_network_error, format_parse_error};
7use crate::providers::shared::Utf8StreamDecoder;
8
9use async_stream::try_stream;
10use async_trait::async_trait;
11use futures::StreamExt;
12use hashbrown::{HashMap, HashSet};
13use serde_json::Value;
14
15use super::super::response_parser;
16
17#[async_trait]
18impl LLMProvider for OpenRouterProvider {
19    fn name(&self) -> &str {
20        "openrouter"
21    }
22
23    fn supports_streaming(&self) -> bool {
24        true
25    }
26
27    fn supports_non_streaming(&self, _model: &str) -> bool {
28        // Pinned so the stream-timeout fallback cannot silently regress.
29        true
30    }
31
32    fn supports_reasoning(&self, model: &str) -> bool {
33        self.model_behavior
34            .as_ref()
35            .and_then(|b| b.model_supports_reasoning)
36            .or_else(|| vtcode_config::models::model_catalog_entry("openrouter", model).map(|entry| entry.reasoning))
37            .unwrap_or(false)
38    }
39
40    fn supports_reasoning_effort(&self, model: &str) -> bool {
41        self.model_behavior
42            .as_ref()
43            .and_then(|b| b.model_supports_reasoning_effort)
44            .or_else(|| {
45                vtcode_config::models::model_catalog_entry("openrouter", model)
46                    .map(|entry| !entry.reasoning_efforts.is_empty())
47            })
48            .unwrap_or(false)
49    }
50
51    fn supports_tools(&self, model: &str) -> bool {
52        use vtcode_config::constants::models;
53        !models::openrouter::TOOL_UNAVAILABLE_MODELS.contains(&model)
54    }
55
56    fn effective_context_size(&self, model: &str) -> usize {
57        crate::provider::catalog_context_window("openrouter", model, 128_000)
58    }
59
60    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
61        let model = self.resolve_model(&request).to_string();
62        let response = self.send_with_fallback(&request, Some(false)).await?;
63
64        let response_json: Value = response.json().await.map_err(|e| format_parse_error("OpenRouter", &e))?;
65
66        let include_cache_metrics = self.prompt_cache_enabled && self.prompt_cache_settings.report_savings;
67        response_parser::parse_response(response_json, model, include_cache_metrics)
68    }
69
70    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
71        let model = self.resolve_model(&request).to_string();
72        let response = self.send_with_fallback(&request, Some(true)).await?;
73
74        let stream = try_stream! {
75            let mut body_stream = response.bytes_stream();
76            let mut buf: Vec<u8> = Vec::new();
77            let mut offset = 0usize;
78            let mut decoder = Utf8StreamDecoder::new();
79            let mut aggregator = crate::providers::shared::StreamAggregator::new(model);
80
81            while let Some(chunk_result) = body_stream.next().await {
82                let chunk = chunk_result.map_err(|e| format_network_error("OpenRouter", &e))?;
83                decoder.push_bytes(&chunk, &mut buf);
84
85                while let Some((split_idx, delimiter_len)) = crate::providers::shared::find_sse_boundary_bytes(&buf, offset) {
86                    let event = std::str::from_utf8(&buf[offset..split_idx]).expect("valid utf-8 stream data");
87                    offset = split_idx + delimiter_len;
88
89                    if let Some(data_payload) = crate::providers::shared::extract_data_payload(event) {
90                        let trimmed = data_payload.trim();
91                        if trimmed.is_empty() || trimmed == "[DONE]" {
92                            continue;
93                        }
94
95                        if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
96                            if let Some(choices) = payload.get("choices").and_then(|v| v.as_array()) {
97                                if let Some(choice) = choices.first() {
98                                    if let Some(delta) = choice.get("delta") {
99                                        // Handle dedicated reasoning field (e.g. reasoning_content or reasoning)
100                                        if let Some(reasoning) = delta
101                                            .get("reasoning_content")
102                                            .or_else(|| delta.get("reasoning"))
103                                            .and_then(|v| v.as_str())
104                                        {
105                                            if let Some(delta) = aggregator.handle_reasoning(reasoning) {
106                                                yield LLMStreamEvent::Reasoning { delta };
107                                            }
108                                        }
109
110                                        // Handle standard content field
111                                        if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
112                                            for ev in aggregator.handle_content(content) {
113                                                yield ev;
114                                            }
115                                        }
116
117                                        // Handle structured reasoning_details field
118                                        if let Some(reasoning_details) = delta
119                                            .get("reasoning_details")
120                                            .and_then(|v| v.as_array())
121                                        {
122                                            let prev_reasoning_len = aggregator.reasoning.len();
123                                            aggregator.set_reasoning_details(reasoning_details);
124
125                                            if let Some(new_reasoning) = crate::providers::common::extract_reasoning_text_from_detail_values(reasoning_details) {
126                                                if new_reasoning.len() > prev_reasoning_len {
127                                                    let delta = new_reasoning[prev_reasoning_len..].to_string();
128                                                    if !delta.trim().is_empty() {
129                                                        yield LLMStreamEvent::Reasoning { delta };
130                                                    }
131                                                }
132                                            }
133                                        }
134
135                                        // Handle tool calls in deltas
136                                        if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
137                                            aggregator.handle_tool_calls(tool_calls);
138                                        }
139                                    }
140
141                                    if let Some(finish_reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
142                                        aggregator.set_finish_reason(crate::providers::common::map_finish_reason_common(finish_reason));
143                                    }
144                                }
145                            }
146
147                            if let Some(usage) = crate::providers::common::parse_usage_openai_format(&payload, true) {
148                                aggregator.set_usage(usage);
149                            }
150                        }
151                    }
152                }
153
154                // Drain the consumed prefix so `buf` stays bounded to the
155                // unprocessed tail rather than growing for the entire stream.
156                if offset > 0 {
157                    buf.drain(..offset);
158                    offset = 0;
159                }
160            }
161
162            yield LLMStreamEvent::Completed { response: Box::new(aggregator.finalize()) };
163        };
164
165        Ok(Box::pin(stream))
166    }
167
168    async fn stream_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
169        let resolved_model = self.resolve_model(&request).to_string();
170        let response = self.send_with_fallback(&request, Some(true)).await?;
171
172        let stream = try_stream! {
173            let mut body_stream = response.bytes_stream();
174            let mut buf: Vec<u8> = Vec::new();
175            let mut offset = 0usize;
176            let mut decoder = Utf8StreamDecoder::new();
177            let mut aggregator = crate::providers::shared::StreamAggregator::new(resolved_model);
178            let mut seen_tool_calls = HashSet::new();
179            let mut fabricated_ids: HashMap<usize, String> = HashMap::new();
180
181            while let Some(chunk_result) = body_stream.next().await {
182                let chunk = chunk_result.map_err(|e| format_network_error("OpenRouter", &e))?;
183                decoder.push_bytes(&chunk, &mut buf);
184
185                while let Some((split_idx, delimiter_len)) =
186                    crate::providers::shared::find_sse_boundary_bytes(&buf, offset)
187                {
188                    let event = std::str::from_utf8(&buf[offset..split_idx]).expect("valid utf-8 stream data");
189                    offset = split_idx + delimiter_len;
190
191                    if let Some(data_payload) =
192                        crate::providers::shared::extract_data_payload(event)
193                    {
194                        let trimmed = data_payload.trim();
195                        if trimmed.is_empty() || trimmed == "[DONE]" {
196                            continue;
197                        }
198
199                        if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
200                            if let Some(choices) = payload.get("choices").and_then(|v| v.as_array()) {
201                                if let Some(choice) = choices.first() {
202                                    if let Some(delta) = choice.get("delta") {
203                                        let reasoning = delta
204                                            .get("reasoning_content")
205                                            .and_then(Value::as_str)
206                                            .filter(|value| !value.is_empty())
207                                            .map(|value| (value, ReasoningSource::Continuation))
208                                            .or_else(|| {
209                                                delta
210                                                    .get("reasoning")
211                                                    .and_then(Value::as_str)
212                                                    .filter(|value| !value.is_empty())
213                                                    .map(|value| (value, ReasoningSource::Raw))
214                                            });
215                                        if let Some((reasoning, source)) = reasoning
216                                            && let Some(delta) = aggregator.handle_reasoning(reasoning)
217                                        {
218                                            yield NormalizedStreamEvent::ReasoningDelta { delta, source };
219                                        }
220
221                                        if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
222                                            for ev in aggregator.handle_content(content) {
223                                                if let LLMStreamEvent::Token { delta } = ev {
224                                                    yield NormalizedStreamEvent::TextDelta { delta };
225                                                } else if let LLMStreamEvent::Reasoning { delta } = ev {
226                                                    yield NormalizedStreamEvent::ReasoningDelta {
227                                                        delta,
228                                                        source: ReasoningSource::Unknown,
229                                                    };
230                                                }
231                                            }
232                                        }
233
234                                        if let Some(reasoning_details) = delta
235                                            .get("reasoning_details")
236                                            .and_then(|v| v.as_array())
237                                        {
238                                            let prev_reasoning_len = aggregator.reasoning.len();
239                                            aggregator.set_reasoning_details(reasoning_details);
240
241                                            if let Some(new_reasoning) =
242                                                crate::providers::common::extract_reasoning_text_from_detail_values(reasoning_details)
243                                            {
244                                                if new_reasoning.len() > prev_reasoning_len {
245                                                    let delta = new_reasoning[prev_reasoning_len..].to_string();
246                                                    if !delta.trim().is_empty() {
247                                                        yield NormalizedStreamEvent::ReasoningDelta {
248                                                            delta,
249                                                            source: ReasoningSource::Continuation,
250                                                        };
251                                                    }
252                                                }
253                                            }
254                                        }
255
256                                        if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
257                                            // Patch fabricated ids into the payload handed to the
258                                            // aggregator so the ids in streamed events match the
259                                            // ids in the finalized response (lifecycle consumers
260                                            // correlate by call_id).
261                                            let mut patched_tool_calls = tool_calls.clone();
262                                            for (position, tool_call) in patched_tool_calls.iter_mut().enumerate() {
263                                                let index = tool_call
264                                                    .get("index")
265                                                    .and_then(|value| value.as_u64())
266                                                    .map(|value| value as usize)
267                                                    .unwrap_or(position);
268                                                let call_id = match tool_call
269                                                    .get("id")
270                                                    .and_then(|value| value.as_str())
271                                                    .filter(|value| !value.is_empty())
272                                                    .map(ToOwned::to_owned)
273                                                {
274                                                    Some(call_id) => call_id,
275                                                    None => {
276                                                        let call_id = fabricated_ids
277                                                            .entry(index)
278                                                            .or_insert_with(crate::providers::shared::generate_tool_call_id)
279                                                            .clone();
280                                                        if let Some(object) = tool_call.as_object_mut() {
281                                                            object.insert(
282                                                                "id".to_string(),
283                                                                Value::String(call_id.clone()),
284                                                            );
285                                                        }
286                                                        call_id
287                                                    }
288                                                };
289                                                if seen_tool_calls.insert(call_id.clone()) {
290                                                    let name = tool_call
291                                                        .get("function")
292                                                        .and_then(|value| value.get("name"))
293                                                        .and_then(|value| value.as_str())
294                                                        .map(ToOwned::to_owned);
295                                                    yield NormalizedStreamEvent::ToolCallStart {
296                                                        call_id: call_id.clone(),
297                                                        name,
298                                                    };
299                                                }
300                                                if let Some(arguments) = tool_call
301                                                    .get("function")
302                                                    .and_then(|value| value.get("arguments"))
303                                                    .and_then(|value| value.as_str())
304                                                {
305                                                    if !arguments.is_empty() {
306                                                        yield NormalizedStreamEvent::ToolCallDelta {
307                                                            call_id: call_id.clone(),
308                                                            delta: arguments.to_string(),
309                                                        };
310                                                    }
311                                                }
312                                            }
313                                            aggregator.handle_tool_calls(&patched_tool_calls);
314                                        }
315                                    }
316
317                                    if let Some(finish_reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
318                                        aggregator.set_finish_reason(crate::providers::common::map_finish_reason_common(finish_reason));
319                                    }
320                                }
321                            }
322
323                            if let Some(usage) = crate::providers::common::parse_usage_openai_format(&payload, true) {
324                                aggregator.set_usage(usage.clone());
325                                yield NormalizedStreamEvent::Usage { usage };
326                            }
327                        }
328                    }
329                }
330
331                // Drain the consumed prefix so `buf` stays bounded to the
332                // unprocessed tail rather than growing for the entire stream.
333                if offset > 0 {
334                    buf.drain(..offset);
335                    offset = 0;
336                }
337            }
338
339            yield NormalizedStreamEvent::Done {
340                response: Box::new(aggregator.finalize()),
341            };
342        };
343
344        Ok(Box::pin(stream))
345    }
346
347    fn supported_models(&self) -> Vec<String> {
348        use vtcode_config::constants::models;
349        models::openrouter::SUPPORTED_MODELS.iter().map(|s| s.to_string()).collect()
350    }
351
352    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
353        if request.messages.is_empty() {
354            return Err(LLMError::InvalidRequest {
355                message: "Messages cannot be empty".to_string(),
356                metadata: None,
357            });
358        }
359        Ok(())
360    }
361}