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 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 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 #[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}