Skip to main content

lash_core/
direct.rs

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