Skip to main content

vtcode_llm/providers/openresponses/provider/
provider_impl.rs

1//! Provider capabilities and native generation/streaming entrypoints.
2
3use super::OpenResponsesProvider;
4use crate::provider::{
5    LLMError, LLMNormalizedStream, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, Message,
6    ResponsesCompactionOptions,
7};
8use crate::providers::error_handling::{format_network_error, format_parse_error};
9use crate::providers::shared::{
10    ResponsesNormalizedStreamOptions, Utf8StreamDecoder, create_responses_normalized_stream,
11};
12use anyhow::Result;
13use async_stream::try_stream;
14use async_trait::async_trait;
15use futures::StreamExt;
16use serde::Deserialize;
17use serde_json::{Value, json};
18
19/// Borrowed fields used by the native Responses SSE hot path.
20///
21/// Responses events carry a discriminator and a small set of fields consumed
22/// by this provider. Deserializing into `Value` first allocates the complete
23/// event tree, including fields that are ignored by the streaming loop.
24#[derive(Debug, Deserialize)]
25struct NativeStreamEventWire<'a> {
26    #[serde(rename = "type")]
27    event_type: Option<&'a str>,
28    #[serde(borrow)]
29    delta: Option<&'a str>,
30    #[serde(borrow)]
31    item_id: Option<&'a str>,
32    item: Option<Value>,
33}
34
35#[async_trait]
36impl LLMProvider for OpenResponsesProvider {
37    fn name(&self) -> &str {
38        "openresponses"
39    }
40
41    fn supports_streaming(&self) -> bool {
42        true
43    }
44
45    fn supports_non_streaming(&self, _model: &str) -> bool {
46        // Pinned so the stream-timeout fallback cannot silently regress.
47        true
48    }
49
50    fn supports_reasoning(&self, _model: &str) -> bool {
51        self.model_behavior
52            .as_ref()
53            .and_then(|b| b.model_supports_reasoning)
54            .unwrap_or(true) // Open Responses usually implies reasoning support
55    }
56
57    fn supports_reasoning_effort(&self, _model: &str) -> bool {
58        self.model_behavior
59            .as_ref()
60            .and_then(|b| b.model_supports_reasoning_effort)
61            .unwrap_or(true)
62    }
63
64    fn supports_responses_compaction(&self, _model: &str) -> bool {
65        self.supports_compaction_endpoint()
66    }
67
68    // OpenResponses exposes the same standalone `/responses/compact` endpoint as
69    // the native OpenAI provider, so it opts into the NativeStandalone dispatch
70    // (`compact_history_with_options`). Without this, the unified dispatch would
71    // route it to NativeInline (Anthropic `compact_20260112` via `generate`),
72    // which OpenResponses cannot serve, silently downgrading auto/recovery
73    // compaction to the local summarization fallback.
74    fn supports_manual_openai_compaction(&self, _model: &str) -> bool {
75        self.supports_compaction_endpoint()
76    }
77
78    async fn compact_history(&self, model: &str, history: &[Message]) -> Result<Vec<Message>, LLMError> {
79        if !self.supports_compaction_endpoint() {
80            return Err(LLMError::Provider {
81                message: "OpenResponses compact endpoint is not supported for this configured base URL".to_string(),
82                metadata: None,
83            });
84        }
85
86        self.compact_history_request(model, history).await
87    }
88
89    // The compact endpoint accepts the common instruction and routing fields;
90    // output-shaping options are intentionally omitted because this endpoint
91    // does not expose them in its request schema.
92    async fn compact_history_with_options(
93        &self,
94        model: &str,
95        history: &[Message],
96        options: &ResponsesCompactionOptions,
97    ) -> Result<Vec<Message>, LLMError> {
98        if !self.supports_compaction_endpoint() {
99            return Err(LLMError::Provider {
100                message: "OpenResponses compact endpoint is not supported for this configured base URL".to_string(),
101                metadata: None,
102            });
103        }
104
105        self.compact_history_request_with_options(model, history, options).await
106    }
107
108    fn supported_models(&self) -> Vec<String> {
109        use vtcode_config::constants::models::openresponses::SUPPORTED_MODELS;
110        SUPPORTED_MODELS.iter().map(|s| s.to_string()).collect()
111    }
112
113    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
114        if request.model.is_empty() {
115            return Err(LLMError::Provider {
116                message: "Model is required for OpenResponses provider".to_string(),
117                metadata: None,
118            });
119        }
120
121        let supported = self.supported_models();
122        if !supported.contains(&request.model) {
123            return Err(LLMError::Provider {
124                message: format!(
125                    "Model '{}' is not supported by OpenResponses provider. Supported models: {}",
126                    request.model,
127                    supported.join(", ")
128                ),
129                metadata: None,
130            });
131        }
132
133        Ok(())
134    }
135
136    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
137        if request.model.is_empty() {
138            request.model = self.model.clone();
139        }
140        let model = request.model.clone();
141
142        // Try native Open Responses endpoint first
143        let payload = self.build_native_payload(&request, false)?;
144        let response = self.send_native_request(&payload).await?;
145
146        // If native endpoint fails with 404, fallback to chat/completions
147        if response.status() == reqwest::StatusCode::NOT_FOUND {
148            return self.generate_fallback(request).await;
149        }
150
151        let json: Value = response.json().await.map_err(|e| format_parse_error("OpenResponses", &e))?;
152
153        Self::parse_native_response_payload(json, model)
154    }
155
156    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
157        if request.model.is_empty() {
158            request.model = self.model.clone();
159        }
160        let model = request.model.clone();
161
162        let payload = self.build_native_payload(&request, true)?;
163        let response = self.send_native_request(&payload).await?;
164
165        if response.status() == reqwest::StatusCode::NOT_FOUND {
166            return self.stream_fallback(request).await;
167        }
168
169        let stream = try_stream! {
170            let mut body_stream = response.bytes_stream();
171            let mut buf: Vec<u8> = Vec::new();
172            let mut offset = 0usize;
173            let mut decoder = Utf8StreamDecoder::new();
174            let mut aggregator = crate::providers::shared::StreamAggregator::new(model);
175
176            while let Some(chunk_result) = body_stream.next().await {
177                let chunk = chunk_result.map_err(|e| format_network_error("OpenResponses", &e))?;
178                decoder.push_bytes(&chunk, &mut buf);
179
180                while let Some(event) =
181                    crate::providers::shared::next_sse_event(&buf, &mut offset).expect("valid utf-8 stream data")
182                {
183
184                    if let Some(data_payload) = crate::providers::shared::extract_data_payload(event) {
185                        let trimmed = data_payload.trim();
186                        if trimmed.is_empty() || trimmed == "[DONE]" {
187                            continue;
188                        }
189
190                        if let Ok(event) = serde_json::from_str::<NativeStreamEventWire<'_>>(trimmed) {
191                            let event_type = event.event_type.unwrap_or("");
192
193                            match event_type {
194                                "response.output_text.delta" => {
195                                    if let Some(delta) = event.delta {
196                                        // Use aggregator's sanitizer to extract reasoning tags from content
197                                        for ev in aggregator.handle_content(delta) {
198                                            yield ev;
199                                        }
200                                    }
201                                }
202                                "response.function_call_arguments.delta" => {
203                                    if let Some(delta) = event.delta {
204                                        let tc_json = json!([{
205                                            "index": 0,
206                                            "id": event.item_id,
207                                            "function": { "arguments": delta }
208                                        }]);
209                                        if let Some(tool_calls) = tc_json.as_array() {
210                                            aggregator.handle_tool_calls(tool_calls);
211                                        }
212                                    }
213                                }
214                                "response.reasoning.delta" => {
215                                    // Legacy/simple reasoning event
216                                    if let Some(delta) = event.delta {
217                                        yield LLMStreamEvent::Reasoning { delta: delta.to_string() };
218                                    }
219                                }
220                                "response.reasoning_content.delta" => {
221                                    // Raw reasoning traces (preferred)
222                                    if let Some(delta) = event.delta {
223                                        yield LLMStreamEvent::Reasoning { delta: delta.to_string() };
224                                    }
225                                }
226                                "response.reasoning_summary_text.delta" => {
227                                    // Summary reasoning (fallback when raw not available)
228                                    if let Some(delta) = event.delta {
229                                        yield LLMStreamEvent::Reasoning { delta: delta.to_string() };
230                                    }
231                                }
232                                // The added event may contain an incomplete
233                                // opaque item. Only the done snapshot is safe
234                                // to replay on the next request.
235                                "response.output_item.done" => {
236                                    if let Some(item) = event.item.as_ref()
237                                        && item.get("type").and_then(Value::as_str) == Some("compaction")
238                                    {
239                                        aggregator.append_reasoning_detail(item);
240                                    }
241                                }
242                                _ => {}
243                            }
244                        }
245                    }
246                }
247
248                // Keep `buf` bounded to the unprocessed tail rather than
249                // growing for the entire stream.
250                crate::providers::shared::drain_consumed_sse(&mut buf, &mut offset);
251            }
252
253            yield LLMStreamEvent::Completed { response: Box::new(aggregator.finalize()) };
254        };
255
256        Ok(Box::pin(stream))
257    }
258
259    async fn stream_normalized(&self, mut request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
260        if request.model.is_empty() {
261            request.model = self.model.clone();
262        }
263        let model = request.model.clone();
264
265        let payload = self.build_native_payload(&request, true)?;
266        let response = self.send_native_request(&payload).await?;
267
268        if response.status() == reqwest::StatusCode::NOT_FOUND {
269            let mut legacy_stream = self.stream_fallback(request).await?;
270            let stream = try_stream! {
271                while let Some(event) = legacy_stream.next().await {
272                    for normalized in event?.into_normalized() {
273                        yield normalized;
274                    }
275                }
276            };
277            return Ok(Box::pin(stream));
278        }
279
280        let emit_reasoning = self.supports_reasoning(&model);
281        Ok(create_responses_normalized_stream(
282            response,
283            ResponsesNormalizedStreamOptions {
284                provider_name: "OpenResponses",
285                model: model.clone(),
286                emit_reasoning,
287                include_cached_prompt_metrics: false,
288            },
289            move |value| Self::parse_native_response_payload(value, model.clone()),
290        ))
291    }
292}