1use crate::llm::transport::LlmTransportError;
2use crate::llm::types::{
3 LlmAttachment, LlmContentBlock, LlmEventSender, LlmJsonSchema, LlmMessage, LlmOutputSpec,
4 LlmRequest, LlmRequestScope, LlmResponse, LlmRole, LlmStreamEvent, LlmTerminalReason,
5 LlmToolChoice,
6};
7use crate::provider::{ModelCapability, ModelEffortValidationCategory, ProviderHandle};
8use crate::{LashSchema, SchemaContract};
9use lash_trace::{TraceContext, TraceError, TraceEvent, TraceSink};
10use std::sync::Arc;
11
12#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DirectRole {
15 System,
16 User,
17 Assistant,
18}
19
20#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21pub enum DirectPart {
22 Text(String),
23 Image(usize),
24}
25
26#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub struct DirectMessage {
28 pub role: DirectRole,
29 pub parts: Vec<DirectPart>,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct DirectJsonSchema {
34 pub name: String,
35 pub schema: SchemaContract,
36 pub strict: bool,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
40pub enum DirectOutputSpec {
41 #[default]
42 Text,
43 JsonObject,
44 JsonSchema(DirectJsonSchema),
45}
46
47#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
48pub struct DirectRequest {
49 pub model: String,
50 #[serde(default)]
51 pub model_variant: crate::ReasoningSelection,
52 #[serde(default, skip_serializing_if = "ModelCapability::is_empty")]
53 pub model_capability: ModelCapability,
54 #[serde(default)]
55 pub messages: Vec<DirectMessage>,
56 #[serde(default)]
57 pub attachments: Vec<LlmAttachment>,
58 #[serde(default)]
59 pub output: DirectOutputSpec,
60 #[serde(default)]
61 pub generation: crate::GenerationOptions,
62 #[serde(default, skip)]
63 pub stream_events: Option<LlmEventSender>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub session_id: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub caused_by: Option<crate::CausalRef>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub replay: Option<crate::RuntimeReplay>,
70}
71
72impl DirectRequest {
73 pub fn text(model: impl Into<String>, prompt: impl Into<String>) -> Self {
74 Self {
75 model: model.into(),
76 model_variant: crate::ReasoningSelection::ProviderDefault,
77 model_capability: ModelCapability::default(),
78 messages: vec![DirectMessage {
79 role: DirectRole::User,
80 parts: vec![DirectPart::Text(prompt.into())],
81 }],
82 attachments: Vec::new(),
83 output: DirectOutputSpec::Text,
84 generation: crate::GenerationOptions::default(),
85 stream_events: None,
86 session_id: None,
87 caused_by: None,
88 replay: None,
89 }
90 }
91
92 pub fn json(model: impl Into<String>, prompt: impl Into<String>) -> Self {
93 Self {
94 output: DirectOutputSpec::JsonObject,
95 ..Self::text(model, prompt)
96 }
97 }
98
99 pub fn json_schema(
100 model: impl Into<String>,
101 prompt: impl Into<String>,
102 schema: DirectJsonSchema,
103 ) -> Self {
104 Self {
105 output: DirectOutputSpec::JsonSchema(schema),
106 ..Self::text(model, prompt)
107 }
108 }
109
110 pub fn with_replay_key(mut self, key: impl Into<String>) -> Self {
111 self.replay = Some(crate::RuntimeReplay { key: key.into() });
112 self
113 }
114
115 pub fn with_caused_by(mut self, caused_by: crate::CausalRef) -> Self {
116 self.caused_by = Some(caused_by);
117 self
118 }
119}
120
121#[derive(Debug, thiserror::Error, Clone)]
122pub enum DirectLlmError {
123 #[error("invalid request: {message}")]
124 InvalidRequest {
125 category: ModelEffortValidationCategory,
126 message: String,
127 },
128 #[error("invalid response: {0}")]
129 InvalidResponse(String),
130 #[error("transport error: {0}")]
131 Transport(#[from] Box<LlmTransportError>),
132}
133
134#[derive(Clone, Debug)]
137pub struct DirectLlmResult {
138 pub response: LlmResponse,
139 pub llm_call: crate::LlmCallRecord,
140}
141
142impl std::ops::Deref for DirectLlmResult {
143 type Target = LlmResponse;
144
145 fn deref(&self) -> &Self::Target {
146 &self.response
147 }
148}
149
150impl DirectLlmResult {
151 pub fn into_response(self) -> LlmResponse {
152 self.response
153 }
154}
155
156pub struct DirectLlmClient {
157 provider: ProviderHandle,
158 trace_sink: Option<Arc<dyn TraceSink>>,
159 trace_context: TraceContext,
160 clock: Arc<dyn crate::Clock>,
161}
162
163impl DirectLlmClient {
164 pub fn new(provider: ProviderHandle) -> Self {
165 Self {
166 provider,
167 trace_sink: None,
168 trace_context: TraceContext::default(),
169 clock: Arc::new(crate::SystemClock),
170 }
171 }
172
173 pub fn with_trace_sink(mut self, sink: Option<Arc<dyn TraceSink>>) -> Self {
174 self.trace_sink = sink;
175 self
176 }
177
178 pub fn with_trace_context(mut self, context: TraceContext) -> Self {
179 self.trace_context = context;
180 self
181 }
182
183 pub fn with_clock(mut self, clock: Arc<dyn crate::Clock>) -> Self {
184 self.clock = clock;
185 self
186 }
187
188 pub fn provider(&self) -> &ProviderHandle {
189 &self.provider
190 }
191
192 pub fn provider_mut(&mut self) -> &mut ProviderHandle {
193 &mut self.provider
194 }
195
196 pub async fn complete(
197 &mut self,
198 mut request: DirectRequest,
199 ) -> Result<DirectLlmResult, DirectLlmError> {
200 request.model_variant = request
204 .model_capability
205 .validate_selection(&request.model, self.provider.kind(), &request.model_variant)
206 .map_err(|error| DirectLlmError::InvalidRequest {
207 category: error.category,
208 message: error.message,
209 })?;
210
211 let output_for_validation = request.output.clone();
212 let model = request.model.clone();
213 let llm_request = build_llm_request(&self.provider, request, model);
214 let llm_call_id = if self.trace_sink.is_some() {
215 let id = uuid::Uuid::new_v4().to_string();
216 crate::trace::emit_trace(
217 &self.trace_sink,
218 &self.trace_context,
219 TraceContext::default().for_llm_call(id.clone()),
220 TraceEvent::LlmCallStarted {
221 request: crate::trace::trace_llm_request(&llm_request),
222 },
223 self.clock.as_ref(),
224 );
225 Some(id)
226 } else {
227 None
228 };
229 match self.provider.complete(llm_request).await {
230 Ok(response) => {
231 if let Err(error) = validate_direct_output(&output_for_validation, &response) {
232 if let Some(llm_call_id) = llm_call_id {
233 crate::trace::emit_trace(
234 &self.trace_sink,
235 &self.trace_context,
236 TraceContext::default().for_llm_call(llm_call_id),
237 TraceEvent::LlmCallFailed {
238 error: TraceError {
239 message: error.to_string(),
240 retryable: false,
241 terminal_reason: Some(
242 LlmTerminalReason::ProviderError.code().to_string(),
243 ),
244 code: Some("invalid_structured_output".to_string()),
245 raw: None,
246 },
247 stream_summary: None,
248 },
249 self.clock.as_ref(),
250 );
251 }
252 return Err(error);
253 }
254 if let Some(llm_call_id) = llm_call_id {
255 crate::trace::emit_trace(
256 &self.trace_sink,
257 &self.trace_context,
258 TraceContext::default().for_llm_call(llm_call_id),
259 TraceEvent::LlmCallCompleted {
260 response: crate::trace::trace_llm_response(
261 response.full_text.clone(),
262 0,
263 Some(response.terminal_reason),
264 crate::trace::trace_output_parts(&response.parts),
265 ),
266 usage: Some(crate::trace::trace_usage_from_llm(&response.usage)),
267 provider_usage: response.provider_usage.clone(),
268 stream_summary: None,
269 },
270 self.clock.as_ref(),
271 );
272 }
273 Ok(DirectLlmResult {
274 response: response.response,
275 llm_call: response.call_record,
276 })
277 }
278 Err(error) => {
279 if let Some(llm_call_id) = llm_call_id {
280 crate::trace::emit_trace(
281 &self.trace_sink,
282 &self.trace_context,
283 TraceContext::default().for_llm_call(llm_call_id),
284 TraceEvent::LlmCallFailed {
285 error: TraceError {
286 message: error.message.clone(),
287 retryable: error.retryable,
288 terminal_reason: Some(error.terminal_reason.code().to_string()),
289 code: error.code.clone(),
290 raw: error.raw.clone(),
291 },
292 stream_summary: None,
293 },
294 self.clock.as_ref(),
295 );
296 }
297 Err(DirectLlmError::from(Box::new(error.error)))
298 }
299 }
300 }
301}
302
303pub(crate) fn build_llm_request(
304 provider: &ProviderHandle,
305 request: DirectRequest,
306 model: String,
307) -> LlmRequest {
308 let stream_events = transport_stream_events_for_direct(provider, request.stream_events);
309 let DirectRequest {
310 model: _,
311 model_variant,
312 model_capability,
313 messages,
314 attachments,
315 output,
316 generation,
317 stream_events: _,
318 session_id,
319 caused_by: _,
320 replay: _,
321 } = request;
322
323 let output_spec = match output {
324 DirectOutputSpec::Text => None,
325 DirectOutputSpec::JsonObject => Some(LlmOutputSpec::JsonObject),
326 DirectOutputSpec::JsonSchema(schema) => Some(LlmOutputSpec::JsonSchema(LlmJsonSchema {
327 name: schema.name,
328 schema: schema.schema,
329 strict: schema.strict,
330 })),
331 };
332
333 let mut llm_messages = Vec::new();
334 for message in messages {
335 let role = match message.role {
336 DirectRole::System => LlmRole::System,
337 DirectRole::User => LlmRole::User,
338 DirectRole::Assistant => LlmRole::Assistant,
339 };
340 let mut blocks: Vec<LlmContentBlock> = Vec::new();
341 for part in message.parts {
342 match part {
343 DirectPart::Text(text) => {
344 if !text.is_empty() {
345 blocks.push(LlmContentBlock::Text {
346 text: text.into(),
347 response_meta: None,
348 cache_breakpoint: false,
349 });
350 }
351 }
352 DirectPart::Image(idx) => {
353 blocks.push(LlmContentBlock::Image {
354 attachment_idx: idx,
355 });
356 }
357 }
358 }
359 if !blocks.is_empty() {
360 llm_messages.push(LlmMessage::new(role, blocks));
361 }
362 }
363
364 let scope = match session_id {
365 Some(session_id) => LlmRequestScope::new(
366 session_id.clone(),
367 format!("{session_id}:frame:direct"),
368 format!("{session_id}:direct"),
369 ),
370 None => {
371 let request_id = uuid::Uuid::new_v4().to_string();
372 LlmRequestScope::new(
373 format!("direct:{request_id}"),
374 format!("direct:{request_id}:frame"),
375 request_id,
376 )
377 }
378 };
379
380 LlmRequest {
381 model,
382 messages: llm_messages,
383 attachments,
384 tools: Vec::new().into(),
385 tool_choice: LlmToolChoice::None,
386 model_variant,
387 model_capability,
388 generation,
389 scope,
390 output_spec,
391 stream_events,
392 provider_trace: None,
393 }
394}
395
396fn validate_direct_output(
397 output: &DirectOutputSpec,
398 response: &LlmResponse,
399) -> Result<(), DirectLlmError> {
400 let DirectOutputSpec::JsonSchema(schema) = output else {
401 return Ok(());
402 };
403 let parsed: serde_json::Value = serde_json::from_str(response.full_text.trim())
404 .map_err(|err| DirectLlmError::InvalidResponse(format!("expected JSON: {err}")))?;
405 LashSchema::new(schema.schema.canonical().clone())
406 .validate(&parsed)
407 .map_err(DirectLlmError::InvalidResponse)
408}
409
410fn transport_stream_events_for_direct(
411 provider: &ProviderHandle,
412 requested: Option<LlmEventSender>,
413) -> Option<LlmEventSender> {
414 if requested.is_some() {
415 return requested;
416 }
417 if provider.requires_streaming() {
418 Some(LlmEventSender::new(|_event: LlmStreamEvent| {}))
419 } else {
420 None
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use crate::llm::types::{LlmOutputPart, LlmTerminalReason, LlmUsage};
428 use crate::provider::{ProviderOptions, ProviderReliability};
429 use crate::testing::TestProvider;
430 use serde_json::json;
431 use std::sync::{Arc, Mutex};
432
433 #[test]
434 fn json_schema_request_preserves_output_schema() {
435 let schema = DirectJsonSchema {
436 name: "answer_shape".to_string(),
437 schema: json!({
438 "type": "object",
439 "properties": {
440 "answer": { "type": "string" }
441 },
442 "required": ["answer"]
443 })
444 .into(),
445 strict: true,
446 };
447
448 let request = DirectRequest::json_schema("model-a", "return json", schema.clone());
449
450 assert_eq!(
451 request.output,
452 DirectOutputSpec::JsonSchema(schema),
453 "DirectRequest::json_schema must carry the requested output schema"
454 );
455 }
456
457 #[test]
458 fn direct_client_provider_accessors_expose_owned_provider_handle() {
459 let provider = TestProvider::builder()
460 .kind("direct-accessor-provider")
461 .serialize_config(|| json!({"provider": "owned"}))
462 .build()
463 .into_handle();
464 let mut client = DirectLlmClient::new(provider);
465
466 assert_eq!(client.provider().kind(), "direct-accessor-provider");
467 assert_eq!(
468 client.provider().to_spec().config,
469 json!({"provider": "owned"})
470 );
471
472 let options = ProviderOptions {
473 reliability: ProviderReliability::default().max_attempts(7),
474 max_output_tokens: Some(123),
475 ..Default::default()
476 };
477 client.provider_mut().set_options(options.clone());
478
479 assert_eq!(client.provider().options(), options);
480 }
481
482 #[tokio::test]
483 async fn direct_client_complete_delegates_to_provider_and_returns_response() {
484 let captured_request: Arc<Mutex<Option<LlmRequest>>> = Arc::new(Mutex::new(None));
485 let captured_for_provider = Arc::clone(&captured_request);
486 let provider = TestProvider::builder()
487 .kind("direct-complete-provider")
488 .complete(move |request| {
489 let captured_for_provider = Arc::clone(&captured_for_provider);
490 async move {
491 *captured_for_provider.lock().expect("capture lock") = Some(request);
492 Ok(LlmResponse {
493 full_text: "provider delegated response".to_string(),
494 parts: vec![LlmOutputPart::Text {
495 text: "provider delegated response".to_string(),
496 response_meta: None,
497 }],
498 usage: LlmUsage {
499 input_tokens: 11,
500 output_tokens: 3,
501 ..Default::default()
502 },
503 terminal_reason: LlmTerminalReason::Stop,
504 ..Default::default()
505 })
506 }
507 })
508 .build()
509 .into_handle();
510 let mut client = DirectLlmClient::new(provider);
511 let mut request = DirectRequest::json("direct-model", "answer as json");
512 request.session_id = Some("direct-session".to_string());
513
514 let response = client
515 .complete(request)
516 .await
517 .expect("direct completion should delegate");
518
519 assert_eq!(response.full_text, "provider delegated response");
520 assert_eq!(response.llm_call.attempts.len(), 1);
521 let captured = captured_request
522 .lock()
523 .expect("capture lock")
524 .clone()
525 .expect("provider should receive a request");
526 assert_eq!(captured.model, "direct-model");
527 assert_eq!(captured.scope.session_id, "direct-session");
528 assert_eq!(captured.scope.agent_frame_id, "direct-session:frame:direct");
529 assert_eq!(captured.scope.request_id, "direct-session:direct");
530 assert!(matches!(
531 captured.output_spec,
532 Some(LlmOutputSpec::JsonObject)
533 ));
534 assert_eq!(captured.messages.len(), 1);
535 }
536
537 #[tokio::test]
538 async fn direct_client_validates_json_schema_output_against_canonical_schema() {
539 let provider = TestProvider::builder()
540 .kind("direct-validation-provider")
541 .complete(|_request| async {
542 Ok(LlmResponse {
543 full_text: r#"{"items":[]}"#.to_string(),
544 terminal_reason: LlmTerminalReason::Stop,
545 ..Default::default()
546 })
547 })
548 .build()
549 .into_handle();
550 let mut client = DirectLlmClient::new(provider);
551 let request = DirectRequest::json_schema(
552 "direct-model",
553 "return items",
554 DirectJsonSchema {
555 name: "items_result".to_string(),
556 schema: json!({
557 "type": "object",
558 "required": ["items"],
559 "properties": {
560 "items": {
561 "type": "array",
562 "minItems": 1,
563 "items": { "type": "string" }
564 }
565 }
566 })
567 .into(),
568 strict: true,
569 },
570 );
571
572 let err = client
573 .complete(request)
574 .await
575 .expect_err("empty items must fail canonical validation");
576
577 assert!(matches!(err, DirectLlmError::InvalidResponse(_)));
578 assert!(err.to_string().contains("items >= 1"));
579 }
580
581 fn reasoning_capability() -> ModelCapability {
582 ModelCapability {
583 reasoning: Some(crate::ReasoningCapability {
584 efforts: ["low", "medium", "high", "max"]
585 .into_iter()
586 .map(String::from)
587 .collect(),
588 aliases: std::collections::BTreeMap::from([(
589 "xhigh".to_string(),
590 "max".to_string(),
591 )]),
592 ..Default::default()
593 }),
594 }
595 }
596
597 #[tokio::test]
598 async fn direct_client_rejects_unsupported_effort_before_provider_call() {
599 let called = Arc::new(Mutex::new(false));
600 let called_for_provider = Arc::clone(&called);
601 let provider = TestProvider::builder()
602 .kind("direct-reject")
603 .complete(move |_request| {
604 let called = Arc::clone(&called_for_provider);
605 async move {
606 *called.lock().expect("called lock") = true;
607 Ok(LlmResponse::default())
608 }
609 })
610 .build()
611 .into_handle();
612 let mut client = DirectLlmClient::new(provider);
613
614 let mut request = DirectRequest::text("direct-model", "hi");
615 request.model_variant = crate::ReasoningSelection::Effort("turbo".to_string());
616 request.model_capability = reasoning_capability();
617
618 let err = client
619 .complete(request)
620 .await
621 .expect_err("unsupported effort must be rejected");
622 assert!(matches!(
623 err,
624 DirectLlmError::InvalidRequest {
625 category: ModelEffortValidationCategory::UnsupportedEffort,
626 ..
627 }
628 ));
629 assert!(err.to_string().contains("Unsupported effort `turbo`"));
630 assert!(
631 !*called.lock().expect("called lock"),
632 "the provider must not be called when the effort is rejected"
633 );
634 }
635
636 #[tokio::test]
637 async fn direct_client_normalizes_alias_effort_into_outgoing_request() {
638 let captured: Arc<Mutex<Option<crate::ReasoningSelection>>> = Arc::new(Mutex::new(None));
639 let captured_for_provider = Arc::clone(&captured);
640 let provider = TestProvider::builder()
641 .kind("direct-alias")
642 .complete(move |request| {
643 let captured = Arc::clone(&captured_for_provider);
644 async move {
645 *captured.lock().expect("capture lock") = Some(request.model_variant.clone());
646 Ok(LlmResponse {
647 full_text: "ok".to_string(),
648 terminal_reason: LlmTerminalReason::Stop,
649 ..Default::default()
650 })
651 }
652 })
653 .build()
654 .into_handle();
655 let mut client = DirectLlmClient::new(provider);
656
657 let mut request = DirectRequest::text("direct-model", "hi");
658 request.model_variant = crate::ReasoningSelection::Effort("XHigh".to_string());
659 request.model_capability = reasoning_capability();
660
661 client.complete(request).await.expect("completion");
662 let seen = captured
663 .lock()
664 .expect("capture lock")
665 .clone()
666 .expect("provider must be called");
667 assert_eq!(
668 seen,
669 crate::ReasoningSelection::Effort("max".to_string()),
670 "alias `XHigh` must clamp to canonical `max` before the provider sees the request"
671 );
672 }
673
674 #[tokio::test]
675 async fn direct_client_rejects_effort_when_model_is_not_configurable() {
676 let provider = TestProvider::builder()
677 .kind("direct-not-configurable")
678 .complete(|_request| async { Ok(LlmResponse::default()) })
679 .build()
680 .into_handle();
681 let mut client = DirectLlmClient::new(provider);
682
683 let mut request = DirectRequest::text("direct-model", "hi");
684 request.model_variant = crate::ReasoningSelection::Effort("high".to_string());
685 let err = client
688 .complete(request)
689 .await
690 .expect_err("effort on a non-configurable model must be rejected");
691 assert!(matches!(
692 err,
693 DirectLlmError::InvalidRequest {
694 category: ModelEffortValidationCategory::EffortNotConfigurable,
695 ..
696 }
697 ));
698 }
699
700 #[tokio::test]
701 async fn direct_client_rejects_missing_mandatory_effort() {
702 let provider = TestProvider::builder()
703 .kind("direct-mandatory")
704 .complete(|_request| async { Ok(LlmResponse::default()) })
705 .build()
706 .into_handle();
707 let mut client = DirectLlmClient::new(provider);
708
709 let mut capability = reasoning_capability();
710 capability.reasoning.as_mut().expect("reasoning").mandatory = true;
711 let mut request = DirectRequest::text("direct-model", "hi");
712 request.model_capability = capability;
713 let err = client
716 .complete(request)
717 .await
718 .expect_err("missing mandatory effort must be rejected");
719 assert!(matches!(
720 err,
721 DirectLlmError::InvalidRequest {
722 category: ModelEffortValidationCategory::EffortRequired,
723 ..
724 }
725 ));
726 }
727
728 #[test]
729 fn build_llm_request_preserves_nonempty_content_and_drops_empty_messages() {
730 let provider = TestProvider::default().into_handle();
731 let request = DirectRequest {
732 model: "input-model".to_string(),
733 messages: vec![
734 DirectMessage {
735 role: DirectRole::System,
736 parts: vec![DirectPart::Text(String::new())],
737 },
738 DirectMessage {
739 role: DirectRole::User,
740 parts: vec![
741 DirectPart::Text("hello".to_string()),
742 DirectPart::Text(String::new()),
743 ],
744 },
745 DirectMessage {
746 role: DirectRole::Assistant,
747 parts: vec![DirectPart::Image(2)],
748 },
749 ],
750 attachments: Vec::new(),
751 output: DirectOutputSpec::Text,
752 generation: crate::GenerationOptions::default(),
753 stream_events: None,
754 session_id: None,
755 model_variant: Default::default(),
756 model_capability: ModelCapability::default(),
757 caused_by: None,
758 replay: None,
759 };
760
761 let llm_request = build_llm_request(&provider, request, "transport-model".to_string());
762
763 assert_eq!(llm_request.model, "transport-model");
764 assert_eq!(
765 llm_request.messages.len(),
766 2,
767 "empty normalized messages must be dropped"
768 );
769 assert_eq!(llm_request.messages[0].role, LlmRole::User);
770 assert_eq!(llm_request.messages[0].blocks.len(), 1);
771 assert!(matches!(
772 &llm_request.messages[0].blocks[0],
773 LlmContentBlock::Text { text, .. } if text.as_ref() == "hello"
774 ));
775 assert_eq!(llm_request.messages[1].role, LlmRole::Assistant);
776 assert!(matches!(
777 &llm_request.messages[1].blocks[0],
778 LlmContentBlock::Image { attachment_idx: 2 }
779 ));
780 }
781
782 #[test]
783 fn build_llm_request_preserves_direct_stream_sender_and_adds_required_noop_sender() {
784 let captured_events: Arc<Mutex<Vec<LlmStreamEvent>>> = Arc::new(Mutex::new(Vec::new()));
785 let captured_for_sender = Arc::clone(&captured_events);
786 let requested_sender = LlmEventSender::new(move |event| {
787 captured_for_sender
788 .lock()
789 .expect("stream event lock")
790 .push(event);
791 });
792 let mut request = DirectRequest::text("model", "prompt");
793 request.stream_events = Some(requested_sender);
794 let provider = TestProvider::default().into_handle();
795
796 let llm_request = build_llm_request(&provider, request, "model".to_string());
797 let sender = llm_request
798 .stream_events
799 .expect("explicit direct stream sender must be preserved");
800 sender.send(LlmStreamEvent::Delta("delta".to_string()));
801 assert_eq!(captured_events.lock().expect("stream event lock").len(), 1);
802
803 let streaming_provider = TestProvider::builder()
804 .requires_streaming(true)
805 .build()
806 .into_handle();
807 let llm_request = build_llm_request(
808 &streaming_provider,
809 DirectRequest::text("model", "prompt"),
810 "model".to_string(),
811 );
812 assert!(
813 llm_request.stream_events.is_some(),
814 "providers that require streaming need a no-op sender even when direct caller did not request one"
815 );
816 }
817}