vtcode_llm/providers/openrouter/provider/
provider_impl.rs1use 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 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 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 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 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 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 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 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 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}