Skip to main content

lucy/
provider.rs

1use std::collections::BTreeMap;
2use std::io::{self, BufRead, BufReader};
3use std::time::Duration;
4
5use reqwest::blocking::Client;
6use reqwest::Client as AsyncClient;
7use serde_json::{json, Value};
8
9use crate::cancellation::CancellationToken;
10use crate::config::LlmSettings;
11use crate::model::{ChatMessage, ChatToolCall};
12use crate::redaction::{conflicts_with_protected_literal, redact_secret, redaction_marker};
13
14pub const PROVIDER_TIMEOUT: Duration = Duration::from_secs(60);
15const MAX_PROVIDER_CONTENT_BYTES: usize = 1024 * 1024;
16const MAX_PROVIDER_REASONING_DETAILS_BYTES: usize = 1024 * 1024;
17const MAX_PROVIDER_TOOL_ARGUMENT_BYTES: usize = 1024 * 1024;
18const MAX_SSE_LINE_BYTES: usize = 64 * 1024;
19const MAX_SSE_EVENT_BYTES: usize = 1024 * 1024;
20const MAX_SSE_STREAM_BYTES: usize = 8 * 1024 * 1024;
21const MAX_SSE_DATA_LINES: usize = 1024;
22const MAX_PROVIDER_TOOL_CALL_ID_BYTES: usize = 16 * 1024;
23const MAX_PROVIDER_TOOL_NAME_BYTES: usize = 16 * 1024;
24const MAX_PROVIDER_ERROR_BYTES: usize = 16 * 1024;
25const CANCELLATION_POLL_INTERVAL: Duration = Duration::from_millis(10);
26const MODEL_METADATA_TIMEOUT: Duration = Duration::from_secs(2);
27const MAX_MODEL_METADATA_BYTES: usize = 4 * 1024 * 1024;
28const COMPACTION_MAX_SUMMARY_TOKENS: usize = 4_096;
29const SPAWN_SUBAGENT_DESCRIPTION: &str = "Start an isolated background task and immediately return its task ID. Continue your own work without waiting; when the worker finishes, Lucy automatically starts a follow-up main-agent turn and delivers the completion result. Do not poll with check_subagent unless you need an intermediate status. The worker has cmd but cannot delegate further.";
30const CHECK_SUBAGENT_DESCRIPTION: &str = "Inspect an in-process background subagent only when you need an intermediate status or an on-demand result. Do not poll repeatedly: when the worker finishes, Lucy automatically starts a follow-up main-agent turn and delivers the result, so continue your own work instead.";
31
32#[derive(Debug)]
33pub struct ProviderError {
34    message: String,
35    cancelled: bool,
36    partial: Option<ProviderTurn>,
37}
38
39impl ProviderError {
40    fn new(message: impl Into<String>) -> Self {
41        Self {
42            message: message.into(),
43            cancelled: false,
44            partial: None,
45        }
46    }
47
48    fn cancelled(partial: ProviderTurn) -> Self {
49        Self {
50            message: "provider stream canceled".to_owned(),
51            cancelled: true,
52            partial: Some(partial),
53        }
54    }
55
56    pub fn is_cancelled(&self) -> bool {
57        self.cancelled
58    }
59
60    pub fn partial_turn(&self) -> Option<&ProviderTurn> {
61        self.partial.as_ref()
62    }
63}
64
65impl std::fmt::Display for ProviderError {
66    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        formatter.write_str(&self.message)
68    }
69}
70
71impl std::error::Error for ProviderError {}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ProviderTurn {
75    pub content: String,
76    pub tool_calls: Vec<ChatToolCall>,
77    pub reasoning_details: Vec<Value>,
78}
79
80pub(crate) enum ProviderStreamEvent {
81    ReasoningStarted,
82    Text(String),
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ProviderModel {
87    pub id: String,
88    pub efforts: Option<Vec<String>>,
89}
90
91pub struct Provider {
92    client: Client,
93    async_client: AsyncClient,
94    endpoint: String,
95    model: String,
96    effort: Option<String>,
97    api_key_env: String,
98    api_key: String,
99}
100
101fn model_efforts(entry: &Value) -> Option<Vec<String>> {
102    let values = entry
103        .get("reasoning")
104        .and_then(|reasoning| reasoning.get("supported_efforts"))
105        .or_else(|| {
106            [
107                "supported_reasoning_efforts",
108                "reasoning_efforts",
109                "reasoning_effort",
110                "efforts",
111            ]
112            .into_iter()
113            .find_map(|key| entry.get(key))
114        })
115        .and_then(Value::as_array)?;
116    let efforts = values
117        .iter()
118        .filter_map(Value::as_str)
119        .map(str::trim)
120        .filter(|value| !value.is_empty())
121        .fold(Vec::new(), |mut efforts, value| {
122            if !efforts.iter().any(|effort| effort == value) {
123                efforts.push(value.to_owned());
124            }
125            efforts
126        });
127    (!efforts.is_empty()).then_some(efforts)
128}
129
130fn context_window_from_models(payload: &Value, model: &str) -> Option<usize> {
131    let models = payload.get("data").and_then(Value::as_array)?;
132    let entry = models.iter().find(|entry| {
133        entry.get("id").and_then(Value::as_str) == Some(model)
134            || entry.get("name").and_then(Value::as_str) == Some(model)
135    })?;
136    [
137        entry.get("context_length"),
138        entry.get("context_window"),
139        entry.get("max_context_length"),
140        entry
141            .get("top_provider")
142            .and_then(|provider| provider.get("context_length")),
143    ]
144    .into_iter()
145    .flatten()
146    .find_map(Value::as_u64)
147    .and_then(|value| usize::try_from(value).ok())
148    .filter(|value| *value > 0)
149}
150
151fn chat_request(
152    model: &str,
153    messages: &[ChatMessage],
154    effort: &Option<String>,
155    include_tools: bool,
156    include_subagents: bool,
157) -> Value {
158    let mut request = json!({
159        "model": model,
160        "messages": messages
161            .iter()
162            .map(ChatMessage::to_openai_value)
163            .collect::<Vec<_>>(),
164        "stream": true,
165    });
166    if include_tools {
167        let mut tools = vec![json!({
168            "type": "function",
169            "function": {
170                "name": "cmd",
171                "description": "Execute a finite shell command in the session starting directory.",
172                "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"], "additionalProperties": false}
173            }
174        })];
175        if include_subagents {
176            tools.push(json!({"type":"function","function":{"name":"spawn_subagent","description":SPAWN_SUBAGENT_DESCRIPTION,"parameters":{"type":"object","properties":{"task":{"type":"string"},"model":{"type":"string"},"effort":{"type":"string"}},"required":["task"],"additionalProperties":false}}}));
177            tools.push(json!({"type":"function","function":{"name":"check_subagent","description":CHECK_SUBAGENT_DESCRIPTION,"parameters":{"type":"object","properties":{"task_id":{"type":"string"}},"required":["task_id"],"additionalProperties":false}}}));
178        }
179        request["tools"] = Value::Array(tools);
180    } else {
181        request["max_tokens"] = json!(COMPACTION_MAX_SUMMARY_TOKENS);
182    }
183    if let Some(effort) = effort {
184        request["reasoning_effort"] = json!(effort);
185    }
186    request
187}
188
189impl Provider {
190    pub fn new(settings: &LlmSettings) -> Result<Self, ProviderError> {
191        let api_key = match std::env::var(&settings.api_key_env) {
192            Ok(api_key) if !api_key.is_empty() => api_key,
193            Ok(_) | Err(_) => return Err(ProviderError::new("missing provider API key")),
194        };
195        if conflicts_with_protected_literal(&api_key) {
196            return Err(ProviderError::new(redact_secret(
197                "API key conflicts with a required structured output literal",
198                Some(&api_key),
199            )));
200        }
201        if redaction_marker(&api_key).is_none() {
202            return Err(ProviderError::new(redact_secret(
203                "API key cannot be safely redacted",
204                Some(&api_key),
205            )));
206        }
207        if settings.model.trim().is_empty() {
208            return Err(ProviderError::new(redact_secret(
209                "missing llm.model; set a model in config.toml",
210                Some(&api_key),
211            )));
212        }
213        let effort = match &settings.effort {
214            Some(value) => {
215                let trimmed = value.trim();
216                if trimmed.is_empty() {
217                    return Err(ProviderError::new(redact_secret(
218                        "llm.effort must not be empty",
219                        Some(&api_key),
220                    )));
221                }
222                Some(trimmed.to_owned())
223            }
224            None => None,
225        };
226        let endpoint = format!(
227            "{}/chat/completions",
228            settings.base_url.trim_end_matches('/')
229        );
230        let client = Client::builder()
231            .timeout(PROVIDER_TIMEOUT)
232            .build()
233            .map_err(|_| {
234                ProviderError::new(redact_secret(
235                    "unable to initialize HTTP client",
236                    Some(&api_key),
237                ))
238            })?;
239        let async_client = AsyncClient::builder()
240            .timeout(PROVIDER_TIMEOUT)
241            .build()
242            .map_err(|_| {
243                ProviderError::new(redact_secret(
244                    "unable to initialize HTTP client",
245                    Some(&api_key),
246                ))
247            })?;
248        Ok(Self {
249            client,
250            async_client,
251            endpoint,
252            model: settings.model.clone(),
253            effort,
254            api_key_env: settings.api_key_env.clone(),
255            api_key,
256        })
257    }
258
259    pub fn api_key(&self) -> &str {
260        &self.api_key
261    }
262
263    pub fn api_key_env(&self) -> &str {
264        &self.api_key_env
265    }
266
267    pub(crate) fn models(&self) -> Result<Vec<ProviderModel>, ProviderError> {
268        let base_url = self
269            .endpoint
270            .strip_suffix("/chat/completions")
271            .ok_or_else(|| ProviderError::new("invalid provider endpoint"))?;
272        let response = self
273            .client
274            .get(format!("{base_url}/models"))
275            .bearer_auth(&self.api_key)
276            .timeout(MODEL_METADATA_TIMEOUT)
277            .send()
278            .map_err(|_| ProviderError::new("unable to load provider models"))?;
279        if !response.status().is_success() {
280            return Err(ProviderError::new("unable to load provider models"));
281        }
282        let bytes = response
283            .bytes()
284            .map_err(|_| ProviderError::new("unable to load provider models"))?;
285        if bytes.len() > MAX_MODEL_METADATA_BYTES {
286            return Err(ProviderError::new(
287                "provider model catalog exceeded the response limit",
288            ));
289        }
290        let payload: Value = serde_json::from_slice(&bytes)
291            .map_err(|_| ProviderError::new("invalid provider model catalog"))?;
292        let models = payload
293            .get("data")
294            .and_then(Value::as_array)
295            .ok_or_else(|| ProviderError::new("invalid provider model catalog"))?;
296        let mut result = models
297            .iter()
298            .filter_map(|entry| {
299                let id = entry
300                    .get("id")
301                    .or_else(|| entry.get("name"))
302                    .and_then(Value::as_str)?
303                    .trim();
304                if id.is_empty() {
305                    return None;
306                }
307                let efforts = model_efforts(entry);
308                Some(ProviderModel {
309                    id: id.to_owned(),
310                    efforts,
311                })
312            })
313            .collect::<Vec<_>>();
314        result.sort_by(|left, right| left.id.cmp(&right.id));
315        result.dedup_by(|left, right| left.id == right.id);
316        Ok(result)
317    }
318
319    /// Query the OpenAI-compatible model catalog for the configured model's
320    /// context window. Providers that do not expose context metadata simply
321    /// return `None`; this lookup is only used by the interactive statusline.
322    pub(crate) fn context_window(&self) -> Option<usize> {
323        let base_url = self.endpoint.strip_suffix("/chat/completions")?;
324        let response = self
325            .client
326            .get(format!("{base_url}/models"))
327            .bearer_auth(&self.api_key)
328            .timeout(MODEL_METADATA_TIMEOUT)
329            .send()
330            .ok()?;
331        if !response.status().is_success() {
332            return None;
333        }
334        let bytes = response.bytes().ok()?;
335        if bytes.len() > MAX_MODEL_METADATA_BYTES {
336            return None;
337        }
338        let payload: Value = serde_json::from_slice(&bytes).ok()?;
339        context_window_from_models(&payload, &self.model)
340    }
341
342    pub fn stream_chat(
343        &self,
344        messages: &[ChatMessage],
345        on_text: &mut dyn FnMut(&str) -> io::Result<()>,
346    ) -> Result<ProviderTurn, ProviderError> {
347        let request = chat_request(&self.model, messages, &self.effort, true, true);
348
349        let response = self
350            .client
351            .post(&self.endpoint)
352            .bearer_auth(&self.api_key)
353            .header("accept", "text/event-stream")
354            .json(&request)
355            .send()
356            .map_err(|_| ProviderError::new("provider request failed"))?;
357        if !response.status().is_success() {
358            return Err(ProviderError::new(format!(
359                "provider returned HTTP status {}",
360                response.status().as_u16()
361            )));
362        }
363
364        let mut content = String::new();
365        let mut tool_calls = BTreeMap::<usize, PartialToolCall>::new();
366        let mut reasoning_details = Vec::new();
367        let mut reasoning_details_bytes = 0;
368        let mut tool_argument_bytes: usize = 0;
369        let mut finish_reason = None;
370        {
371            let mut on_data = |data: Value| -> Result<(), ProviderError> {
372                if let Some(message) = provider_error_message(&data) {
373                    return Err(ProviderError::new(format!(
374                        "provider stream error: {}",
375                        redact_secret(message, Some(&self.api_key))
376                    )));
377                }
378                let Some(choice) = data
379                    .get("choices")
380                    .and_then(Value::as_array)
381                    .and_then(|choices| choices.first())
382                else {
383                    return Ok(());
384                };
385                if let Some(reason) = validate_finish_reason(choice)? {
386                    finish_reason = Some(reason.to_owned());
387                }
388                let Some(delta) = choice.get("delta") else {
389                    return Ok(());
390                };
391                append_reasoning_details(
392                    &mut reasoning_details,
393                    &mut reasoning_details_bytes,
394                    delta,
395                )?;
396                if let Some(text) = delta.get("content").and_then(Value::as_str) {
397                    if content.len().saturating_add(text.len()) > MAX_PROVIDER_CONTENT_BYTES {
398                        return Err(ProviderError::new(
399                            "provider assistant content exceeded the response limit",
400                        ));
401                    }
402                    content.push_str(text);
403                    if on_text(text).is_err() {
404                        return Err(ProviderError::new("unable to emit assistant delta"));
405                    }
406                }
407                if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
408                    for (position, call) in calls.iter().enumerate() {
409                        let index = call
410                            .get("index")
411                            .and_then(Value::as_u64)
412                            .map_or(position, |index| index as usize);
413                        let partial = tool_calls.entry(index).or_default();
414                        if let Some(id) = call.get("id").and_then(Value::as_str) {
415                            append_provider_field(
416                                &mut partial.id,
417                                id,
418                                MAX_PROVIDER_TOOL_CALL_ID_BYTES,
419                                "provider tool-call id exceeded the response limit",
420                            )?;
421                        }
422                        if let Some(function) = call.get("function") {
423                            if let Some(name) = function.get("name").and_then(Value::as_str) {
424                                append_provider_field(
425                                    &mut partial.name,
426                                    name,
427                                    MAX_PROVIDER_TOOL_NAME_BYTES,
428                                    "provider tool-call name exceeded the response limit",
429                                )?;
430                            }
431                            if let Some(arguments) =
432                                function.get("arguments").and_then(Value::as_str)
433                            {
434                                if tool_argument_bytes.saturating_add(arguments.len())
435                                    > MAX_PROVIDER_TOOL_ARGUMENT_BYTES
436                                {
437                                    return Err(ProviderError::new(
438                                        "provider tool arguments exceeded the response limit",
439                                    ));
440                                }
441                                tool_argument_bytes += arguments.len();
442                                partial.arguments.push_str(arguments);
443                            }
444                        }
445                    }
446                }
447                if let Some(function_call) = delta.get("function_call") {
448                    let partial = tool_calls.entry(0).or_default();
449                    if let Some(name) = function_call.get("name").and_then(Value::as_str) {
450                        append_provider_field(
451                            &mut partial.name,
452                            name,
453                            MAX_PROVIDER_TOOL_NAME_BYTES,
454                            "provider tool-call name exceeded the response limit",
455                        )?;
456                    }
457                    if let Some(arguments) = function_call.get("arguments").and_then(Value::as_str)
458                    {
459                        if tool_argument_bytes.saturating_add(arguments.len())
460                            > MAX_PROVIDER_TOOL_ARGUMENT_BYTES
461                        {
462                            return Err(ProviderError::new(
463                                "provider tool arguments exceeded the response limit",
464                            ));
465                        }
466                        tool_argument_bytes += arguments.len();
467                        partial.arguments.push_str(arguments);
468                    }
469                }
470                Ok(())
471            };
472            let mut reader = BufReader::new(response);
473            let parse_result = parse_sse(&mut reader, &mut on_data)?;
474            if !parse_result.received_payload {
475                return Err(ProviderError::new(
476                    "provider stream contained no valid payload",
477                ));
478            }
479            if !parse_result.received_done {
480                return Err(ProviderError::new("provider stream ended before [DONE]"));
481            }
482        }
483
484        let tool_calls = tool_calls
485            .into_iter()
486            .map(|(index, partial)| ChatToolCall {
487                id: if partial.id.is_empty() {
488                    format!("call_{index}")
489                } else {
490                    partial.id
491                },
492                name: partial.name,
493                arguments: partial.arguments,
494            })
495            .collect::<Vec<_>>();
496        if let Some(reason) = finish_reason.as_deref() {
497            if !tool_calls.is_empty() && !matches!(reason, "tool_calls" | "function_call") {
498                return Err(ProviderError::new(
499                    "provider tool calls ended with an incompatible finish reason",
500                ));
501            }
502            if tool_calls.is_empty() && matches!(reason, "tool_calls" | "function_call") {
503                return Err(ProviderError::new(
504                    "provider reported tool completion without a tool call",
505                ));
506            }
507        }
508        if content.is_empty() && tool_calls.is_empty() {
509            return Err(ProviderError::new(
510                "provider stream contained no assistant content or tool calls",
511            ));
512        }
513        Ok(ProviderTurn {
514            content,
515            tool_calls,
516            reasoning_details,
517        })
518    }
519
520    /// Generate an internal compaction summary without exposing `cmd` to the
521    /// summarization request or emitting its text as a normal assistant delta.
522    pub(crate) fn summarize(
523        &self,
524        messages: &[ChatMessage],
525        cancellation: &CancellationToken,
526    ) -> Result<String, ProviderError> {
527        let mut ignored = |_text: &str| Ok(());
528        let turn = self.stream_chat_cancellable_with_options(
529            messages,
530            &mut ignored,
531            cancellation,
532            false,
533            false,
534        )?;
535        if !turn.tool_calls.is_empty() {
536            return Err(ProviderError::new(
537                "compaction summary requested an unsupported tool",
538            ));
539        }
540        if turn.content.trim().is_empty() {
541            return Err(ProviderError::new("compaction summary was empty"));
542        }
543        Ok(turn.content)
544    }
545
546    /// Stream through an async response so cancellation can drop the pending
547    /// socket read instead of waiting for the blocking client's timeout.
548    #[allow(dead_code)]
549    pub(crate) fn stream_chat_cancellable(
550        &self,
551        messages: &[ChatMessage],
552        on_text: &mut dyn FnMut(&str) -> io::Result<()>,
553        cancellation: &CancellationToken,
554    ) -> Result<ProviderTurn, ProviderError> {
555        self.stream_chat_cancellable_with_options(messages, on_text, cancellation, true, true)
556    }
557
558    pub(crate) fn stream_chat_cancellable_with_options(
559        &self,
560        messages: &[ChatMessage],
561        on_text: &mut dyn FnMut(&str) -> io::Result<()>,
562        cancellation: &CancellationToken,
563        include_tools: bool,
564        include_subagents: bool,
565    ) -> Result<ProviderTurn, ProviderError> {
566        let mut on_event = |event| match event {
567            ProviderStreamEvent::ReasoningStarted => Ok(()),
568            ProviderStreamEvent::Text(text) => on_text(&text),
569        };
570        self.stream_chat_cancellable_with_options_and_events(
571            messages,
572            &mut on_event,
573            cancellation,
574            include_tools,
575            include_subagents,
576        )
577    }
578
579    pub(crate) fn stream_chat_cancellable_with_options_and_events(
580        &self,
581        messages: &[ChatMessage],
582        on_event: &mut dyn FnMut(ProviderStreamEvent) -> io::Result<()>,
583        cancellation: &CancellationToken,
584        include_tools: bool,
585        include_subagents: bool,
586    ) -> Result<ProviderTurn, ProviderError> {
587        let runtime = tokio::runtime::Builder::new_current_thread()
588            .enable_all()
589            .build()
590            .map_err(|_| ProviderError::new("unable to initialize provider runtime"))?;
591        runtime.block_on(self.stream_chat_async(
592            messages,
593            on_event,
594            cancellation,
595            include_tools,
596            include_subagents,
597        ))
598    }
599
600    async fn stream_chat_async(
601        &self,
602        messages: &[ChatMessage],
603        on_event: &mut dyn FnMut(ProviderStreamEvent) -> io::Result<()>,
604        cancellation: &CancellationToken,
605        include_tools: bool,
606        include_subagents: bool,
607    ) -> Result<ProviderTurn, ProviderError> {
608        if cancellation.is_cancelled() {
609            return Err(ProviderError::cancelled(ProviderTurn {
610                content: String::new(),
611                tool_calls: Vec::new(),
612                reasoning_details: Vec::new(),
613            }));
614        }
615        let request = chat_request(
616            &self.model,
617            messages,
618            &self.effort,
619            include_tools,
620            include_subagents,
621        );
622        let request = self
623            .async_client
624            .post(&self.endpoint)
625            .bearer_auth(&self.api_key)
626            .header("accept", "text/event-stream")
627            .json(&request)
628            .send();
629        let mut request = Box::pin(request);
630        let mut response = loop {
631            if cancellation.is_cancelled() {
632                return Err(ProviderError::cancelled(ProviderTurn {
633                    content: String::new(),
634                    tool_calls: Vec::new(),
635                    reasoning_details: Vec::new(),
636                }));
637            }
638            match tokio::time::timeout(CANCELLATION_POLL_INTERVAL, request.as_mut()).await {
639                Ok(response) => {
640                    break response.map_err(|_| ProviderError::new("provider request failed"))?;
641                }
642                Err(_) => continue,
643            }
644        };
645        if !response.status().is_success() {
646            return Err(ProviderError::new(format!(
647                "provider returned HTTP status {}",
648                response.status().as_u16()
649            )));
650        }
651
652        let mut accumulator = ProviderAccumulator::default();
653        let mut decoder = SseDecoder::default();
654        loop {
655            if cancellation.is_cancelled() {
656                return Err(ProviderError::cancelled(accumulator.partial_turn()));
657            }
658            let chunk =
659                match tokio::time::timeout(CANCELLATION_POLL_INTERVAL, response.chunk()).await {
660                    Ok(chunk) => {
661                        chunk.map_err(|_| ProviderError::new("unable to read provider stream"))?
662                    }
663                    Err(_) => continue,
664                };
665            let Some(chunk) = chunk else {
666                break;
667            };
668            let done = decoder.feed(&chunk, &mut |data| {
669                accumulator.on_data(data, &self.api_key, on_event)
670            })?;
671            if done {
672                break;
673            }
674        }
675        if cancellation.is_cancelled() {
676            return Err(ProviderError::cancelled(accumulator.partial_turn()));
677        }
678        let parse_result =
679            decoder.finish(&mut |data| accumulator.on_data(data, &self.api_key, on_event))?;
680        if !parse_result.received_payload {
681            return Err(ProviderError::new(
682                "provider stream contained no valid payload",
683            ));
684        }
685        if !parse_result.received_done {
686            return Err(ProviderError::new("provider stream ended before [DONE]"));
687        }
688        accumulator.finish()
689    }
690}
691
692#[derive(Debug, Clone, Default)]
693struct PartialToolCall {
694    id: String,
695    name: String,
696    arguments: String,
697}
698
699#[derive(Debug, Default)]
700struct ProviderAccumulator {
701    content: String,
702    tool_calls: BTreeMap<usize, PartialToolCall>,
703    reasoning_details: Vec<Value>,
704    reasoning_details_bytes: usize,
705    tool_argument_bytes: usize,
706    finish_reason: Option<String>,
707    reasoning_started: bool,
708}
709
710impl ProviderAccumulator {
711    fn on_data(
712        &mut self,
713        data: Value,
714        api_key: &str,
715        on_event: &mut dyn FnMut(ProviderStreamEvent) -> io::Result<()>,
716    ) -> Result<(), ProviderError> {
717        if let Some(message) = provider_error_message(&data) {
718            return Err(ProviderError::new(format!(
719                "provider stream error: {}",
720                redact_secret(message, Some(api_key))
721            )));
722        }
723        let Some(choice) = data
724            .get("choices")
725            .and_then(Value::as_array)
726            .and_then(|choices| choices.first())
727        else {
728            return Ok(());
729        };
730        if let Some(reason) = validate_finish_reason(choice)? {
731            self.finish_reason = Some(reason.to_owned());
732        }
733        let Some(delta) = choice.get("delta") else {
734            return Ok(());
735        };
736        let received_reasoning = append_reasoning_details(
737            &mut self.reasoning_details,
738            &mut self.reasoning_details_bytes,
739            delta,
740        )?;
741        if received_reasoning && !self.reasoning_started {
742            self.reasoning_started = true;
743            on_event(ProviderStreamEvent::ReasoningStarted)
744                .map_err(|_| ProviderError::new("unable to emit reasoning state"))?;
745        }
746        if let Some(text) = delta.get("content").and_then(Value::as_str) {
747            if self.content.len().saturating_add(text.len()) > MAX_PROVIDER_CONTENT_BYTES {
748                return Err(ProviderError::new(
749                    "provider assistant content exceeded the response limit",
750                ));
751            }
752            self.content.push_str(text);
753            on_event(ProviderStreamEvent::Text(text.to_owned()))
754                .map_err(|_| ProviderError::new("unable to emit assistant delta"))?;
755        }
756        if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
757            for (position, call) in calls.iter().enumerate() {
758                let index = call
759                    .get("index")
760                    .and_then(Value::as_u64)
761                    .map_or(position, |index| index as usize);
762                let partial = self.tool_calls.entry(index).or_default();
763                if let Some(id) = call.get("id").and_then(Value::as_str) {
764                    append_provider_field(
765                        &mut partial.id,
766                        id,
767                        MAX_PROVIDER_TOOL_CALL_ID_BYTES,
768                        "provider tool-call id exceeded the response limit",
769                    )?;
770                }
771                if let Some(function) = call.get("function") {
772                    if let Some(name) = function.get("name").and_then(Value::as_str) {
773                        append_provider_field(
774                            &mut partial.name,
775                            name,
776                            MAX_PROVIDER_TOOL_NAME_BYTES,
777                            "provider tool-call name exceeded the response limit",
778                        )?;
779                    }
780                    if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
781                        if self.tool_argument_bytes.saturating_add(arguments.len())
782                            > MAX_PROVIDER_TOOL_ARGUMENT_BYTES
783                        {
784                            return Err(ProviderError::new(
785                                "provider tool arguments exceeded the response limit",
786                            ));
787                        }
788                        self.tool_argument_bytes += arguments.len();
789                        partial.arguments.push_str(arguments);
790                    }
791                }
792            }
793        }
794        if let Some(function_call) = delta.get("function_call") {
795            let partial = self.tool_calls.entry(0).or_default();
796            if let Some(name) = function_call.get("name").and_then(Value::as_str) {
797                append_provider_field(
798                    &mut partial.name,
799                    name,
800                    MAX_PROVIDER_TOOL_NAME_BYTES,
801                    "provider tool-call name exceeded the response limit",
802                )?;
803            }
804            if let Some(arguments) = function_call.get("arguments").and_then(Value::as_str) {
805                if self.tool_argument_bytes.saturating_add(arguments.len())
806                    > MAX_PROVIDER_TOOL_ARGUMENT_BYTES
807                {
808                    return Err(ProviderError::new(
809                        "provider tool arguments exceeded the response limit",
810                    ));
811                }
812                self.tool_argument_bytes += arguments.len();
813                partial.arguments.push_str(arguments);
814            }
815        }
816        Ok(())
817    }
818
819    fn partial_turn(&self) -> ProviderTurn {
820        ProviderTurn {
821            content: self.content.clone(),
822            tool_calls: self
823                .tool_calls
824                .iter()
825                .map(|(index, partial)| ChatToolCall {
826                    id: if partial.id.is_empty() {
827                        format!("call_{index}")
828                    } else {
829                        partial.id.clone()
830                    },
831                    name: partial.name.clone(),
832                    arguments: partial.arguments.clone(),
833                })
834                .collect(),
835            reasoning_details: self.reasoning_details.clone(),
836        }
837    }
838
839    fn finish(self) -> Result<ProviderTurn, ProviderError> {
840        let tool_calls = self
841            .tool_calls
842            .into_iter()
843            .map(|(index, partial)| ChatToolCall {
844                id: if partial.id.is_empty() {
845                    format!("call_{index}")
846                } else {
847                    partial.id
848                },
849                name: partial.name,
850                arguments: partial.arguments,
851            })
852            .collect::<Vec<_>>();
853        if let Some(reason) = self.finish_reason.as_deref() {
854            if !tool_calls.is_empty() && !matches!(reason, "tool_calls" | "function_call") {
855                return Err(ProviderError::new(
856                    "provider tool calls ended with an incompatible finish reason",
857                ));
858            }
859            if tool_calls.is_empty() && matches!(reason, "tool_calls" | "function_call") {
860                return Err(ProviderError::new(
861                    "provider reported tool completion without a tool call",
862                ));
863            }
864        }
865        if self.content.is_empty() && tool_calls.is_empty() {
866            return Err(ProviderError::new(
867                "provider stream contained no assistant content or tool calls",
868            ));
869        }
870        Ok(ProviderTurn {
871            content: self.content,
872            tool_calls,
873            reasoning_details: self.reasoning_details,
874        })
875    }
876}
877
878fn append_reasoning_details(
879    target: &mut Vec<Value>,
880    serialized_bytes: &mut usize,
881    delta: &Value,
882) -> Result<bool, ProviderError> {
883    let Some(details) = delta.get("reasoning_details").and_then(Value::as_array) else {
884        return Ok(false);
885    };
886    if details.is_empty() {
887        return Ok(false);
888    }
889    let serialized_delta = serde_json::to_vec(details)
890        .map_err(|_| ProviderError::new("provider reasoning details could not be serialized"))?;
891    let combined_bytes = if target.is_empty() {
892        serialized_delta.len()
893    } else {
894        serialized_bytes
895            .saturating_add(serialized_delta.len())
896            .saturating_sub(1)
897    };
898    if combined_bytes > MAX_PROVIDER_REASONING_DETAILS_BYTES {
899        return Err(ProviderError::new(
900            "provider reasoning details exceeded the response limit",
901        ));
902    }
903    target.extend(details.iter().cloned());
904    *serialized_bytes = combined_bytes;
905    Ok(true)
906}
907
908fn append_provider_field(
909    target: &mut String,
910    fragment: &str,
911    limit: usize,
912    error_message: &str,
913) -> Result<(), ProviderError> {
914    if target.len().saturating_add(fragment.len()) > limit {
915        return Err(ProviderError::new(error_message));
916    }
917    target.push_str(fragment);
918    Ok(())
919}
920
921fn provider_error_message(data: &Value) -> Option<&str> {
922    let error = data.get("error")?;
923    let message = if let Some(message) = error.get("message").and_then(Value::as_str) {
924        message
925    } else if let Some(message) = error.as_str() {
926        message
927    } else {
928        return Some("provider returned an error payload");
929    };
930    if message.len() > MAX_PROVIDER_ERROR_BYTES {
931        Some("provider error text exceeded the response limit")
932    } else {
933        Some(message)
934    }
935}
936
937#[derive(Debug, Default)]
938struct SseDecoder {
939    line: Vec<u8>,
940    data_lines: Vec<String>,
941    data_event_bytes: usize,
942    stream_bytes: usize,
943    result: SseParseResult,
944    done: bool,
945}
946
947impl SseDecoder {
948    fn feed<F>(&mut self, bytes: &[u8], on_data: &mut F) -> Result<bool, ProviderError>
949    where
950        F: FnMut(Value) -> Result<(), ProviderError>,
951    {
952        if self.stream_bytes.saturating_add(bytes.len()) > MAX_SSE_STREAM_BYTES {
953            return Err(ProviderError::new(
954                "provider SSE stream exceeded the response limit",
955            ));
956        }
957        self.stream_bytes += bytes.len();
958        for byte in bytes {
959            if self.done {
960                break;
961            }
962            if *byte == b'\n' {
963                let line = std::mem::take(&mut self.line);
964                if self.process_line(&line, on_data)? {
965                    return Ok(true);
966                }
967            } else {
968                self.line.push(*byte);
969                if self.line.len() > MAX_SSE_LINE_BYTES {
970                    return Err(ProviderError::new(
971                        "provider SSE line exceeded the response limit",
972                    ));
973                }
974            }
975        }
976        Ok(self.done)
977    }
978
979    fn finish<F>(&mut self, on_data: &mut F) -> Result<SseParseResult, ProviderError>
980    where
981        F: FnMut(Value) -> Result<(), ProviderError>,
982    {
983        if !self.line.is_empty() && !self.done {
984            let line = std::mem::take(&mut self.line);
985            self.process_line(&line, on_data)?;
986        }
987        if !self.done {
988            self.dispatch_data(on_data)?;
989        }
990        Ok(self.result)
991    }
992
993    fn process_line<F>(&mut self, raw_line: &[u8], on_data: &mut F) -> Result<bool, ProviderError>
994    where
995        F: FnMut(Value) -> Result<(), ProviderError>,
996    {
997        let line = std::str::from_utf8(raw_line)
998            .map_err(|_| ProviderError::new("unable to read provider stream"))?
999            .trim_end_matches('\r');
1000        if line.is_empty() {
1001            return self.dispatch_data(on_data);
1002        }
1003        if line.starts_with(':') {
1004            return Ok(false);
1005        }
1006        let (field, value) = line
1007            .split_once(':')
1008            .map_or((line, ""), |(field, value)| (field, value));
1009        if field == "data" {
1010            let value = value.strip_prefix(' ').unwrap_or(value);
1011            let separator_bytes = (!self.data_lines.is_empty()) as usize;
1012            let added_bytes = separator_bytes.saturating_add(value.len());
1013            if self.data_event_bytes.saturating_add(added_bytes) > MAX_SSE_EVENT_BYTES {
1014                return Err(ProviderError::new(
1015                    "provider SSE data event exceeded the response limit",
1016                ));
1017            }
1018            if self.data_lines.len() >= MAX_SSE_DATA_LINES {
1019                return Err(ProviderError::new(
1020                    "provider SSE data line count exceeded the response limit",
1021                ));
1022            }
1023            self.data_event_bytes += added_bytes;
1024            self.data_lines.push(value.to_owned());
1025        }
1026        Ok(false)
1027    }
1028
1029    fn dispatch_data<F>(&mut self, on_data: &mut F) -> Result<bool, ProviderError>
1030    where
1031        F: FnMut(Value) -> Result<(), ProviderError>,
1032    {
1033        if self.data_lines.is_empty() {
1034            self.data_event_bytes = 0;
1035            return Ok(false);
1036        }
1037        let data = self.data_lines.join("\n");
1038        self.data_lines.clear();
1039        self.data_event_bytes = 0;
1040        if data.trim().is_empty() {
1041            return Ok(false);
1042        }
1043        if data == "[DONE]" {
1044            self.result.received_done = true;
1045            self.done = true;
1046            return Ok(true);
1047        }
1048        let value: Value = serde_json::from_str(&data)
1049            .map_err(|_| ProviderError::new("provider sent malformed SSE data"))?;
1050        self.result.received_payload = true;
1051        on_data(value)?;
1052        Ok(false)
1053    }
1054}
1055
1056#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1057pub struct SseParseResult {
1058    pub received_payload: bool,
1059    pub received_done: bool,
1060}
1061
1062pub fn parse_sse<R, F>(reader: &mut R, mut on_data: F) -> Result<SseParseResult, ProviderError>
1063where
1064    R: BufRead,
1065    F: FnMut(Value) -> Result<(), ProviderError>,
1066{
1067    let mut data_lines = Vec::new();
1068    let mut data_event_bytes = 0;
1069    let mut stream_bytes: usize = 0;
1070    let mut result = SseParseResult::default();
1071    let mut line = Vec::with_capacity(MAX_SSE_LINE_BYTES);
1072    loop {
1073        let (has_line, line_bytes) = read_sse_line(reader, &mut line)?;
1074        if stream_bytes.saturating_add(line_bytes) > MAX_SSE_STREAM_BYTES {
1075            return Err(ProviderError::new(
1076                "provider SSE stream exceeded the response limit",
1077            ));
1078        }
1079        stream_bytes += line_bytes;
1080        if !has_line {
1081            if !data_lines.is_empty() {
1082                dispatch_data(
1083                    &mut data_lines,
1084                    &mut data_event_bytes,
1085                    &mut on_data,
1086                    &mut result,
1087                )?;
1088            }
1089            return Ok(result);
1090        }
1091
1092        let line = std::str::from_utf8(&line)
1093            .map_err(|_| ProviderError::new("unable to read provider stream"))?
1094            .trim_end_matches('\r');
1095        if line.is_empty() {
1096            if dispatch_data(
1097                &mut data_lines,
1098                &mut data_event_bytes,
1099                &mut on_data,
1100                &mut result,
1101            )? {
1102                return Ok(result);
1103            }
1104            continue;
1105        }
1106        if line.starts_with(':') {
1107            continue;
1108        }
1109        let (field, value) = line
1110            .split_once(':')
1111            .map_or((line, ""), |(field, value)| (field, value));
1112        if field == "data" {
1113            let value = value.strip_prefix(' ').unwrap_or(value);
1114            let separator_bytes = (!data_lines.is_empty()) as usize;
1115            let added_bytes = separator_bytes.saturating_add(value.len());
1116            if data_event_bytes.saturating_add(added_bytes) > MAX_SSE_EVENT_BYTES {
1117                return Err(ProviderError::new(
1118                    "provider SSE data event exceeded the response limit",
1119                ));
1120            }
1121            if data_lines.len() >= MAX_SSE_DATA_LINES {
1122                return Err(ProviderError::new(
1123                    "provider SSE data line count exceeded the response limit",
1124                ));
1125            }
1126            data_event_bytes += added_bytes;
1127            data_lines.push(value.to_owned());
1128        }
1129    }
1130}
1131
1132fn read_sse_line<R: BufRead>(
1133    reader: &mut R,
1134    line: &mut Vec<u8>,
1135) -> Result<(bool, usize), ProviderError> {
1136    line.clear();
1137    let mut consumed_bytes = 0;
1138    loop {
1139        let buffer = reader
1140            .fill_buf()
1141            .map_err(|_| ProviderError::new("unable to read provider stream"))?;
1142        if buffer.is_empty() {
1143            return Ok((!line.is_empty(), consumed_bytes));
1144        }
1145
1146        let newline = buffer.iter().position(|byte| *byte == b'\n');
1147        let chunk_length = newline.unwrap_or(buffer.len());
1148        if line.len().saturating_add(chunk_length) > MAX_SSE_LINE_BYTES {
1149            return Err(ProviderError::new(
1150                "provider SSE line exceeded the response limit",
1151            ));
1152        }
1153        line.extend_from_slice(&buffer[..chunk_length]);
1154        let consumed = newline.map_or(chunk_length, |index| index + 1);
1155        reader.consume(consumed);
1156        consumed_bytes += consumed;
1157        if newline.is_some() {
1158            return Ok((true, consumed_bytes));
1159        }
1160    }
1161}
1162
1163fn dispatch_data<F>(
1164    data_lines: &mut Vec<String>,
1165    data_event_bytes: &mut usize,
1166    on_data: &mut F,
1167    result: &mut SseParseResult,
1168) -> Result<bool, ProviderError>
1169where
1170    F: FnMut(Value) -> Result<(), ProviderError>,
1171{
1172    if data_lines.is_empty() {
1173        *data_event_bytes = 0;
1174        return Ok(false);
1175    }
1176    let data = data_lines.join("\n");
1177    data_lines.clear();
1178    *data_event_bytes = 0;
1179    if data.trim().is_empty() {
1180        return Ok(false);
1181    }
1182    if data == "[DONE]" {
1183        result.received_done = true;
1184        return Ok(true);
1185    }
1186    let value: Value = serde_json::from_str(&data)
1187        .map_err(|_| ProviderError::new("provider sent malformed SSE data"))?;
1188    result.received_payload = true;
1189    on_data(value)?;
1190    Ok(false)
1191}
1192
1193fn validate_finish_reason(choice: &Value) -> Result<Option<&str>, ProviderError> {
1194    let Some(reason) = choice.get("finish_reason") else {
1195        return Ok(None);
1196    };
1197    if reason.is_null() {
1198        return Ok(None);
1199    }
1200    match reason.as_str() {
1201        Some("stop") | Some("tool_calls") | Some("function_call") => Ok(reason.as_str()),
1202        Some("length") | Some("content_filter") => Err(ProviderError::new(
1203            "provider response ended before completion",
1204        )),
1205        Some(_) | None => Err(ProviderError::new(
1206            "provider response has an unsupported finish reason",
1207        )),
1208    }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214    use std::io::{Cursor, Read, Write};
1215    use std::net::TcpListener;
1216    use std::sync::mpsc;
1217    use std::thread;
1218    use std::time::Instant;
1219
1220    #[test]
1221    fn subagent_tool_descriptions_prefer_automatic_completion_over_polling() {
1222        let request = chat_request(
1223            "model",
1224            &[ChatMessage::user("hello".to_owned())],
1225            &None,
1226            true,
1227            true,
1228        );
1229        let tools = request["tools"].as_array().expect("model tools");
1230        let description = |name: &str| {
1231            tools
1232                .iter()
1233                .find(|tool| tool["function"]["name"] == name)
1234                .and_then(|tool| tool["function"]["description"].as_str())
1235                .expect("tool description")
1236        };
1237
1238        let spawn = description("spawn_subagent");
1239        assert!(spawn.contains("Continue your own work without waiting"));
1240        assert!(spawn.contains("automatically starts a follow-up main-agent turn"));
1241        assert!(spawn.contains("Do not poll with check_subagent"));
1242
1243        let check = description("check_subagent");
1244        assert!(check.contains("Do not poll repeatedly"));
1245        assert!(check.contains("automatically starts a follow-up main-agent turn"));
1246        assert!(check.contains("continue your own work instead"));
1247    }
1248
1249    #[test]
1250    fn compaction_request_does_not_include_tools() {
1251        let normal = chat_request(
1252            "model",
1253            &[ChatMessage::user("hello".to_owned())],
1254            &None,
1255            true,
1256            true,
1257        );
1258        let compact = chat_request(
1259            "model",
1260            &[ChatMessage::user("hello".to_owned())],
1261            &None,
1262            false,
1263            false,
1264        );
1265
1266        assert!(normal.get("tools").is_some());
1267        assert!(normal.get("max_tokens").is_none());
1268        assert!(compact.get("tools").is_none());
1269        assert_eq!(compact["max_tokens"], COMPACTION_MAX_SUMMARY_TOKENS);
1270    }
1271
1272    #[test]
1273    fn model_catalog_reads_nested_and_compatible_effort_metadata() {
1274        let openrouter = serde_json::json!({
1275            "reasoning": {
1276                "supported_efforts": ["max", "xhigh", "high", "medium", "low", "none"]
1277            }
1278        });
1279        assert_eq!(
1280            model_efforts(&openrouter),
1281            Some(vec![
1282                "max".to_owned(),
1283                "xhigh".to_owned(),
1284                "high".to_owned(),
1285                "medium".to_owned(),
1286                "low".to_owned(),
1287                "none".to_owned(),
1288            ])
1289        );
1290
1291        let compatible = serde_json::json!({
1292            "supported_reasoning_efforts": ["light", "medium", "max", "light", ""]
1293        });
1294        assert_eq!(
1295            model_efforts(&compatible),
1296            Some(vec![
1297                "light".to_owned(),
1298                "medium".to_owned(),
1299                "max".to_owned()
1300            ])
1301        );
1302        assert_eq!(model_efforts(&serde_json::json!({})), None);
1303    }
1304
1305    #[test]
1306    fn model_catalog_context_window_matches_configured_model() {
1307        let payload = serde_json::json!({
1308            "data": [
1309                {"id": "other", "context_length": 8_000},
1310                {"id": "provider/model", "context_length": 128_000}
1311            ]
1312        });
1313
1314        assert_eq!(
1315            context_window_from_models(&payload, "provider/model"),
1316            Some(128_000)
1317        );
1318        assert_eq!(context_window_from_models(&payload, "missing"), None);
1319    }
1320
1321    #[test]
1322    fn model_catalog_context_window_accepts_provider_fallback_fields() {
1323        let payload = serde_json::json!({
1324            "data": [{
1325                "id": "provider/model",
1326                "top_provider": {"context_length": 64_000}
1327            }]
1328        });
1329
1330        assert_eq!(
1331            context_window_from_models(&payload, "provider/model"),
1332            Some(64_000)
1333        );
1334    }
1335
1336    #[test]
1337    fn parses_sse_comments_multiline_data_and_done() {
1338        let stream = b": keep-alive\n\ndata: \n\ndata: {\"choices\":[]\ndata: }\n\ndata: [DONE]\n";
1339        let mut values = Vec::new();
1340        let result = parse_sse(&mut Cursor::new(stream), |value| {
1341            values.push(value);
1342            Ok(())
1343        })
1344        .expect("SSE");
1345        assert!(result.received_payload);
1346        assert!(result.received_done);
1347        assert_eq!(values.len(), 1);
1348        assert!(values[0]["choices"].is_array());
1349    }
1350
1351    #[test]
1352    fn parses_text_and_fragmented_tool_calls() {
1353        let first = serde_json::json!({
1354            "choices": [{"delta": {"content": "hi"}}]
1355        });
1356        let second = serde_json::json!({
1357            "choices": [{
1358                "delta": {
1359                    "tool_calls": [{
1360                        "index": 0,
1361                        "id": "c1",
1362                        "function": {"name": "cmd", "arguments": "{command:"}
1363                    }]
1364                }
1365            }]
1366        });
1367        let third = serde_json::json!({
1368            "choices": [{
1369                "delta": {
1370                    "tool_calls": [{
1371                        "index": 0,
1372                        "function": {"arguments": "pwd}"}
1373                    }]
1374                }
1375            }]
1376        });
1377        let stream =
1378            format!("data: {first}\n\n data: {second}\n\ndata: {third}\n\ndata: [DONE]\n\n")
1379                .replace(" data:", "data:");
1380        let mut content = String::new();
1381        let mut calls = BTreeMap::<usize, PartialToolCall>::new();
1382        let result = parse_sse(&mut Cursor::new(stream.as_bytes()), |value| {
1383            let choice = &value["choices"][0];
1384            let delta = &choice["delta"];
1385            if let Some(text) = delta["content"].as_str() {
1386                content.push_str(text);
1387            }
1388            if let Some(tool_calls) = delta["tool_calls"].as_array() {
1389                for call in tool_calls {
1390                    let index = call["index"].as_u64().expect("index") as usize;
1391                    let partial = calls.entry(index).or_default();
1392                    partial.id.push_str(call["id"].as_str().unwrap_or(""));
1393                    partial
1394                        .name
1395                        .push_str(call["function"]["name"].as_str().unwrap_or(""));
1396                    partial
1397                        .arguments
1398                        .push_str(call["function"]["arguments"].as_str().unwrap_or(""));
1399                }
1400            }
1401            Ok(())
1402        })
1403        .expect("SSE");
1404        assert!(result.received_payload);
1405        assert!(result.received_done);
1406        assert_eq!(content, "hi");
1407        assert_eq!(calls[&0].id, "c1");
1408        assert_eq!(calls[&0].name, "cmd");
1409        assert_eq!(calls[&0].arguments, "{command:pwd}");
1410    }
1411
1412    #[test]
1413    fn cancellable_accumulator_accepts_more_than_sixty_four_tool_calls() {
1414        let mut accumulator = ProviderAccumulator::default();
1415        let tool_calls = (0..65)
1416            .map(|index| {
1417                serde_json::json!({
1418                    "index": index,
1419                    "id": format!("call-{index}"),
1420                    "function": {
1421                        "name": "cmd",
1422                        "arguments": "{\"command\":\"true\"}"
1423                    }
1424                })
1425            })
1426            .collect::<Vec<_>>();
1427        accumulator
1428            .on_data(
1429                serde_json::json!({
1430                    "choices": [{
1431                        "delta": {"tool_calls": tool_calls},
1432                        "finish_reason": "tool_calls"
1433                    }]
1434                }),
1435                "provider-secret",
1436                &mut |_| Ok(()),
1437            )
1438            .expect("tool-call chunk");
1439
1440        let turn = accumulator.finish().expect("provider turn");
1441        assert_eq!(turn.tool_calls.len(), 65);
1442    }
1443
1444    #[test]
1445    fn reasoning_stream_event_is_emitted_once_before_assistant_text() {
1446        let mut accumulator = ProviderAccumulator::default();
1447        let mut events = Vec::new();
1448        let mut on_event = |event| {
1449            match event {
1450                ProviderStreamEvent::ReasoningStarted => events.push("started".to_owned()),
1451                ProviderStreamEvent::Text(text) => events.push(text),
1452            }
1453            Ok(())
1454        };
1455
1456        accumulator
1457            .on_data(
1458                serde_json::json!({
1459                    "choices": [{
1460                        "delta": {
1461                            "reasoning_details": [{"type": "reasoning.text", "text": "thinking"}]
1462                        }
1463                    }]
1464                }),
1465                "provider-secret",
1466                &mut on_event,
1467            )
1468            .expect("reasoning chunk");
1469        accumulator
1470            .on_data(
1471                serde_json::json!({
1472                    "choices": [{"delta": {"content": "answer"}}]
1473                }),
1474                "provider-secret",
1475                &mut on_event,
1476            )
1477            .expect("answer chunk");
1478
1479        assert_eq!(events, vec!["started".to_owned(), "answer".to_owned()]);
1480    }
1481
1482    #[test]
1483    fn accumulates_reasoning_details_with_fragmented_tool_calls() {
1484        let mut accumulator = ProviderAccumulator::default();
1485        accumulator
1486            .on_data(
1487                serde_json::json!({
1488                    "choices": [{
1489                        "delta": {
1490                            "reasoning_details": [{
1491                                "type": "reasoning.text",
1492                                "text": "part one"
1493                            }]
1494                        }
1495                    }]
1496                }),
1497                "provider-secret",
1498                &mut |_| Ok(()),
1499            )
1500            .expect("first provider chunk");
1501        accumulator
1502            .on_data(
1503                serde_json::json!({
1504                    "choices": [{
1505                        "delta": {
1506                            "reasoning_details": [{
1507                                "type": "reasoning.text",
1508                                "text": "part two"
1509                            }],
1510                            "tool_calls": [{
1511                                "index": 0,
1512                                "id": "call-1",
1513                                "function": {
1514                                    "name": "cmd",
1515                                    "arguments": "{\"command\":\"true\"}"
1516                                }
1517                            }]
1518                        },
1519                        "finish_reason": "tool_calls"
1520                    }]
1521                }),
1522                "provider-secret",
1523                &mut |_| Ok(()),
1524            )
1525            .expect("second provider chunk");
1526
1527        let partial = accumulator.partial_turn();
1528        assert_eq!(partial.reasoning_details.len(), 2);
1529        let turn = accumulator.finish().expect("provider turn");
1530        assert_eq!(
1531            turn.reasoning_details,
1532            vec![
1533                json!({"type": "reasoning.text", "text": "part one"}),
1534                json!({"type": "reasoning.text", "text": "part two"}),
1535            ]
1536        );
1537        assert_eq!(turn.tool_calls.len(), 1);
1538        assert_eq!(turn.tool_calls[0].name, "cmd");
1539    }
1540
1541    #[test]
1542    fn accumulates_many_small_reasoning_details_and_rejects_overflow_atomically() {
1543        const FRAGMENT_COUNT: usize = 4096;
1544        let mut details = Vec::new();
1545        let mut serialized_bytes = 0;
1546        let delta = serde_json::json!({
1547            "reasoning_details": [{
1548                "type": "reasoning.text",
1549                "text": "x".repeat(64)
1550            }]
1551        });
1552        for _ in 0..FRAGMENT_COUNT {
1553            append_reasoning_details(&mut details, &mut serialized_bytes, &delta)
1554                .expect("small reasoning detail");
1555        }
1556        assert_eq!(details.len(), FRAGMENT_COUNT);
1557
1558        let first_chunk_delta = serde_json::json!({
1559            "reasoning_details": [{
1560                "type": "reasoning.text",
1561                "text": "x".repeat(500 * 1024)
1562            }]
1563        });
1564        let first_chunk_bytes = serde_json::to_vec(&first_chunk_delta["reasoning_details"])
1565            .expect("first reasoning detail chunk")
1566            .len();
1567        assert!(first_chunk_bytes < MAX_PROVIDER_REASONING_DETAILS_BYTES);
1568        append_reasoning_details(&mut details, &mut serialized_bytes, &first_chunk_delta)
1569            .expect("first individually bounded reasoning detail chunk");
1570        assert!(serialized_bytes <= MAX_PROVIDER_REASONING_DETAILS_BYTES);
1571
1572        let second_chunk_delta = serde_json::json!({
1573            "reasoning_details": [{
1574                "type": "reasoning.text",
1575                "text": "x".repeat(200 * 1024)
1576            }]
1577        });
1578        let second_chunk_bytes = serde_json::to_vec(&second_chunk_delta["reasoning_details"])
1579            .expect("second reasoning detail chunk")
1580            .len();
1581        assert!(second_chunk_bytes < MAX_PROVIDER_REASONING_DETAILS_BYTES);
1582        assert!(
1583            serialized_bytes
1584                .saturating_add(second_chunk_bytes)
1585                .saturating_sub(1)
1586                > MAX_PROVIDER_REASONING_DETAILS_BYTES
1587        );
1588
1589        let prior_details = details.clone();
1590        let prior_bytes = serialized_bytes;
1591        let error =
1592            append_reasoning_details(&mut details, &mut serialized_bytes, &second_chunk_delta)
1593                .expect_err("reasoning details limit");
1594
1595        assert_eq!(
1596            error.to_string(),
1597            "provider reasoning details exceeded the response limit"
1598        );
1599        assert_eq!(details, prior_details);
1600        assert_eq!(serialized_bytes, prior_bytes);
1601        assert_eq!(
1602            serde_json::to_vec(&details)
1603                .expect("accumulated reasoning details")
1604                .len(),
1605            serialized_bytes
1606        );
1607        assert!(serialized_bytes <= MAX_PROVIDER_REASONING_DETAILS_BYTES);
1608    }
1609
1610    #[test]
1611    fn rejects_reasoning_details_that_exceed_the_serialized_response_limit_before_retaining_them() {
1612        let mut accumulator = ProviderAccumulator::default();
1613        let retained = serde_json::json!({
1614            "choices": [{
1615                "delta": {
1616                    "reasoning_details": [{
1617                        "type": "reasoning.text",
1618                        "text": "retained"
1619                    }]
1620                }
1621            }]
1622        });
1623        accumulator
1624            .on_data(retained, "provider-secret", &mut |_| Ok(()))
1625            .expect("details within limit");
1626
1627        let oversized = "x".repeat(MAX_PROVIDER_REASONING_DETAILS_BYTES);
1628        let error = accumulator
1629            .on_data(
1630                serde_json::json!({
1631                    "choices": [{
1632                        "delta": {
1633                            "reasoning_details": [{"text": oversized}]
1634                        }
1635                    }]
1636                }),
1637                "provider-secret",
1638                &mut |_| Ok(()),
1639            )
1640            .expect_err("reasoning details limit");
1641        assert_eq!(
1642            error.to_string(),
1643            "provider reasoning details exceeded the response limit"
1644        );
1645        assert_eq!(
1646            accumulator.reasoning_details,
1647            vec![serde_json::json!({
1648                "type": "reasoning.text",
1649                "text": "retained"
1650            })]
1651        );
1652    }
1653
1654    #[test]
1655    fn accepts_compatible_finish_reasons_and_rejects_incomplete_ones() {
1656        for reason in [
1657            None,
1658            Some(Value::Null),
1659            Some(Value::String("stop".to_owned())),
1660        ] {
1661            let mut choice = serde_json::json!({"delta": {}});
1662            if let Some(reason) = reason {
1663                choice["finish_reason"] = reason;
1664            }
1665            validate_finish_reason(&choice).expect("compatible finish reason");
1666        }
1667        for reason in ["tool_calls", "function_call"] {
1668            validate_finish_reason(&serde_json::json!({
1669                "delta": {},
1670                "finish_reason": reason
1671            }))
1672            .expect("tool finish reason");
1673        }
1674        for reason in ["length", "content_filter", "error"] {
1675            assert!(validate_finish_reason(&serde_json::json!({
1676                "delta": {},
1677                "finish_reason": reason
1678            }))
1679            .is_err());
1680        }
1681    }
1682
1683    #[test]
1684    fn rejects_api_keys_that_conflict_with_fixed_literals() {
1685        for (index, secret) in [
1686            "session",
1687            "tool",
1688            "cmd",
1689            "command",
1690            "finite",
1691            "0",
1692            ":",
1693            "[REDACTED]",
1694        ]
1695        .into_iter()
1696        .enumerate()
1697        {
1698            let environment = format!("LUCY_PROVIDER_CONFLICT_{}_{}", std::process::id(), index);
1699            std::env::set_var(&environment, secret);
1700            let settings = LlmSettings {
1701                base_url: "http://localhost".to_owned(),
1702                model: "model".to_owned(),
1703                api_key_env: environment.clone(),
1704                effort: None,
1705            };
1706            let error = match Provider::new(&settings) {
1707                Ok(_) => panic!("fixed literal conflict should be rejected: {secret}"),
1708                Err(error) => error,
1709            };
1710            assert!(error.to_string().contains("structured output"));
1711            assert!(!error.to_string().contains(secret));
1712            std::env::remove_var(environment);
1713        }
1714    }
1715
1716    #[test]
1717    fn accepts_a_normal_long_provider_key() {
1718        let environment = format!("LUCY_PROVIDER_NORMAL_{}", std::process::id());
1719        std::env::set_var(&environment, "provider-secret");
1720        let settings = LlmSettings {
1721            base_url: "http://localhost".to_owned(),
1722            model: "model".to_owned(),
1723            api_key_env: environment.clone(),
1724            effort: None,
1725        };
1726        assert!(Provider::new(&settings).is_ok());
1727        std::env::remove_var(environment);
1728    }
1729
1730    #[test]
1731    fn accepts_a_configurable_effort() {
1732        let environment = format!("LUCY_PROVIDER_EFFORT_OK_{}", std::process::id());
1733        std::env::set_var(&environment, "provider-secret");
1734        let settings = LlmSettings {
1735            base_url: "http://localhost".to_owned(),
1736            model: "model".to_owned(),
1737            api_key_env: environment.clone(),
1738            effort: Some("high".to_owned()),
1739        };
1740        assert!(Provider::new(&settings).is_ok());
1741        std::env::remove_var(environment);
1742    }
1743
1744    #[test]
1745    fn empty_effort_is_rejected_without_echoing_the_key() {
1746        let environment = format!("LUCY_PROVIDER_EFFORT_EMPTY_{}", std::process::id());
1747        std::env::set_var(&environment, "provider-secret");
1748        for effort in ["", "   ", "\t"] {
1749            let settings = LlmSettings {
1750                base_url: "http://localhost".to_owned(),
1751                model: "model".to_owned(),
1752                api_key_env: environment.clone(),
1753                effort: Some(effort.to_owned()),
1754            };
1755            let error = match Provider::new(&settings) {
1756                Ok(_) => panic!("empty effort should be rejected: {effort:?}"),
1757                Err(error) => error,
1758            };
1759            assert!(error.to_string().contains("llm.effort must not be empty"));
1760            assert!(!error.to_string().contains("provider-secret"));
1761        }
1762        std::env::remove_var(environment);
1763    }
1764
1765    #[test]
1766    fn missing_api_key_error_does_not_echo_the_environment_name() {
1767        let environment = format!("LUCY_MISSING_KEY_{}", std::process::id());
1768        std::env::remove_var(&environment);
1769        let settings = LlmSettings {
1770            base_url: "http://localhost".to_owned(),
1771            model: "model".to_owned(),
1772            api_key_env: environment.clone(),
1773            effort: None,
1774        };
1775        let error = match Provider::new(&settings) {
1776            Ok(_) => panic!("missing key should be rejected"),
1777            Err(error) => error,
1778        };
1779        assert_eq!(error.to_string(), "missing provider API key");
1780        assert!(!error.to_string().contains(&environment));
1781    }
1782
1783    #[test]
1784    fn cancellable_stream_stops_a_stalled_provider_without_waiting_for_timeout() {
1785        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
1786        let address = listener.local_addr().expect("address");
1787        let (sent, sent_receiver) = mpsc::channel();
1788        let server = thread::spawn(move || {
1789            let (mut stream, _) = listener.accept().expect("request");
1790            let mut request = std::io::BufReader::new(stream.try_clone().expect("clone"));
1791            let mut content_length = 0;
1792            loop {
1793                let mut line = String::new();
1794                request.read_line(&mut line).expect("header");
1795                if line == "\r\n" {
1796                    break;
1797                }
1798                if let Some(value) = line.strip_prefix("Content-Length:") {
1799                    content_length = value.trim().parse::<usize>().expect("length");
1800                }
1801            }
1802            let mut body = vec![0; content_length];
1803            request.read_exact(&mut body).expect("body");
1804
1805            let payload = serde_json::json!({
1806                "choices": [{"delta": {"content": "partial"}, "finish_reason": null}]
1807            });
1808            let event = format!("data: {payload}\n\n");
1809            let response = format!(
1810                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n{:x}\r\n{}\r\n",
1811                event.len(), event
1812            );
1813            stream.write_all(response.as_bytes()).expect("response");
1814            stream.flush().expect("flush");
1815            sent.send(()).expect("body readiness");
1816            thread::sleep(Duration::from_millis(500));
1817        });
1818
1819        let environment = format!("LUCY_PROVIDER_CANCEL_{}", std::process::id());
1820        std::env::set_var(&environment, "provider-secret");
1821        let provider = Provider::new(&LlmSettings {
1822            base_url: format!("http://{address}/v1"),
1823            model: "model".to_owned(),
1824            api_key_env: environment.clone(),
1825            effort: None,
1826        })
1827        .expect("provider");
1828        let token = CancellationToken::new();
1829        let worker_token = token.clone();
1830        let worker = thread::spawn(move || {
1831            let mut received = String::new();
1832            let result = provider.stream_chat_cancellable(
1833                &[ChatMessage::user("hello".to_owned())],
1834                &mut |text| {
1835                    received.push_str(text);
1836                    Ok(())
1837                },
1838                &worker_token,
1839            );
1840            (result, received)
1841        });
1842        sent_receiver
1843            .recv_timeout(Duration::from_secs(1))
1844            .expect("body was sent");
1845        let started = Instant::now();
1846        assert!(token.cancel());
1847        let (result, received) = worker.join().expect("provider worker");
1848        assert!(started.elapsed() < Duration::from_millis(400));
1849        let error = result.expect_err("cancellation");
1850        assert!(error.is_cancelled());
1851        assert!(received.is_empty() || received == "partial");
1852        server.join().expect("server");
1853        std::env::remove_var(environment);
1854    }
1855
1856    #[test]
1857    fn rejects_an_oversized_sse_line_before_json_parsing() {
1858        let stream = format!("data: {}\n\n", "x".repeat(MAX_SSE_LINE_BYTES));
1859        let error = parse_sse(&mut Cursor::new(stream.as_bytes()), |_| Ok(())).expect_err("limit");
1860        assert_eq!(
1861            error.to_string(),
1862            "provider SSE line exceeded the response limit"
1863        );
1864    }
1865
1866    #[test]
1867    fn rejects_an_oversized_sse_data_event_before_json_parsing() {
1868        let payload = "x".repeat(MAX_SSE_LINE_BYTES - "data: ".len());
1869        let line_count = MAX_SSE_EVENT_BYTES / payload.len() + 2;
1870        let mut stream = String::new();
1871        for _ in 0..line_count {
1872            stream.push_str("data: ");
1873            stream.push_str(&payload);
1874            stream.push('\n');
1875        }
1876        stream.push('\n');
1877
1878        let error = parse_sse(&mut Cursor::new(stream.as_bytes()), |_| Ok(())).expect_err("limit");
1879        assert_eq!(
1880            error.to_string(),
1881            "provider SSE data event exceeded the response limit"
1882        );
1883    }
1884
1885    #[test]
1886    fn rejects_an_oversized_sse_stream_of_ignored_fields() {
1887        let line = format!("ignored: {}\n", "x".repeat(1024));
1888        let mut stream = Vec::new();
1889        while stream.len() <= MAX_SSE_STREAM_BYTES {
1890            stream.extend_from_slice(line.as_bytes());
1891        }
1892
1893        let error = parse_sse(&mut Cursor::new(stream), |_| Ok(())).expect_err("limit");
1894        assert_eq!(
1895            error.to_string(),
1896            "provider SSE stream exceeded the response limit"
1897        );
1898    }
1899
1900    #[test]
1901    fn rejects_too_many_empty_sse_data_lines() {
1902        let stream = format!("{}\n", "data:\n".repeat(MAX_SSE_DATA_LINES + 1));
1903        let error = parse_sse(&mut Cursor::new(stream.as_bytes()), |_| Ok(())).expect_err("limit");
1904        assert_eq!(
1905            error.to_string(),
1906            "provider SSE data line count exceeded the response limit"
1907        );
1908    }
1909
1910    #[test]
1911    fn reports_eof_before_done_as_incomplete() {
1912        let stream = b"data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n";
1913        let result = parse_sse(&mut Cursor::new(stream), |_| Ok(())).expect("SSE parse");
1914        assert!(result.received_payload);
1915        assert!(!result.received_done);
1916    }
1917
1918    #[test]
1919    fn reports_empty_non_sse_input_without_payload_or_done() {
1920        let result =
1921            parse_sse(&mut Cursor::new(b"not an SSE response\n"), |_| Ok(())).expect("SSE parse");
1922        assert_eq!(result, SseParseResult::default());
1923    }
1924
1925    #[test]
1926    fn caps_accumulated_tool_call_id_and_name_fields() {
1927        let fragment = "x".repeat(MAX_PROVIDER_TOOL_CALL_ID_BYTES);
1928        let mut id = String::new();
1929        append_provider_field(
1930            &mut id,
1931            &fragment,
1932            MAX_PROVIDER_TOOL_CALL_ID_BYTES,
1933            "provider tool-call id exceeded the response limit",
1934        )
1935        .expect("id within limit");
1936        let error = append_provider_field(
1937            &mut id,
1938            "x",
1939            MAX_PROVIDER_TOOL_CALL_ID_BYTES,
1940            "provider tool-call id exceeded the response limit",
1941        )
1942        .expect_err("id limit");
1943        assert_eq!(
1944            error.to_string(),
1945            "provider tool-call id exceeded the response limit"
1946        );
1947
1948        let fragment = "x".repeat(MAX_PROVIDER_TOOL_NAME_BYTES);
1949        let mut name = String::new();
1950        append_provider_field(
1951            &mut name,
1952            &fragment,
1953            MAX_PROVIDER_TOOL_NAME_BYTES,
1954            "provider tool-call name exceeded the response limit",
1955        )
1956        .expect("name within limit");
1957        let error = append_provider_field(
1958            &mut name,
1959            "x",
1960            MAX_PROVIDER_TOOL_NAME_BYTES,
1961            "provider tool-call name exceeded the response limit",
1962        )
1963        .expect_err("name limit");
1964        assert_eq!(
1965            error.to_string(),
1966            "provider tool-call name exceeded the response limit"
1967        );
1968    }
1969
1970    #[test]
1971    fn caps_provider_error_text_without_copying_the_full_message() {
1972        let message = "x".repeat(MAX_PROVIDER_ERROR_BYTES + 1);
1973        let value = serde_json::json!({"error": {"message": message}});
1974        assert_eq!(
1975            provider_error_message(&value),
1976            Some("provider error text exceeded the response limit")
1977        );
1978    }
1979
1980    #[test]
1981    fn reports_midstream_error_without_echoing_provider_body() {
1982        let stream = b"data: {\"error\":{\"message\":\"bad request\"}}\n\n";
1983        let error = parse_sse(&mut Cursor::new(stream), |value| {
1984            if let Some(message) = provider_error_message(&value) {
1985                return Err(ProviderError::new(format!(
1986                    "provider stream error: {message}"
1987                )));
1988            }
1989            Ok(())
1990        })
1991        .expect_err("error");
1992        assert_eq!(error.to_string(), "provider stream error: bad request");
1993    }
1994}