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