1use std::cell::RefCell;
49use std::time::Duration;
50
51use crate::llm::api::{DeltaSender, LlmRequestPayload, LlmResult, ProviderTelemetry};
52use crate::llm::provider::{LlmProvider, LlmProviderChat};
53use crate::value::{ErrorCategory, VmError};
54
55#[derive(Clone, Debug)]
57pub enum FakeLlmTurn {
58 Stream(Vec<FakeLlmEvent>),
61 Error(FakeLlmError),
64 Stalled(Duration),
68}
69
70impl FakeLlmTurn {
71 pub fn stream(events: impl IntoIterator<Item = FakeLlmEvent>) -> Self {
73 Self::Stream(events.into_iter().collect())
74 }
75
76 pub fn error(category: ErrorCategory, message: impl Into<String>) -> Self {
78 Self::Error(FakeLlmError {
79 category,
80 message: message.into(),
81 retry_after_ms: None,
82 })
83 }
84}
85
86#[derive(Clone, Debug)]
88pub enum FakeLlmEvent {
89 Token(String),
92 ToolCallDelta {
95 id: String,
96 name: String,
97 arguments: serde_json::Value,
98 },
99 Stall(Duration),
102 Error(FakeLlmError),
105 Done(FakeStopReason),
108}
109
110#[derive(Clone, Debug, PartialEq, Eq)]
112pub enum FakeStopReason {
113 EndTurn,
114 ToolUse,
115 MaxTokens,
116 StopSequence,
117 Custom(String),
118}
119
120impl FakeStopReason {
121 fn as_str(&self) -> &str {
122 match self {
123 Self::EndTurn => "end_turn",
124 Self::ToolUse => "tool_use",
125 Self::MaxTokens => "max_tokens",
126 Self::StopSequence => "stop_sequence",
127 Self::Custom(value) => value.as_str(),
128 }
129 }
130}
131
132#[derive(Clone, Debug)]
134pub struct FakeLlmError {
135 pub category: ErrorCategory,
136 pub message: String,
137 pub retry_after_ms: Option<u64>,
141}
142
143impl FakeLlmError {
144 pub fn new(category: ErrorCategory, message: impl Into<String>) -> Self {
145 Self {
146 category,
147 message: message.into(),
148 retry_after_ms: None,
149 }
150 }
151
152 pub fn with_retry_after_ms(mut self, ms: u64) -> Self {
153 self.retry_after_ms = Some(ms);
154 self
155 }
156}
157
158#[derive(Clone, Debug, Default)]
165pub struct FakeLlmScript {
166 pub turns: Vec<FakeLlmTurn>,
167}
168
169impl FakeLlmScript {
170 pub fn new() -> Self {
171 Self::default()
172 }
173
174 pub fn streaming(events: impl IntoIterator<Item = FakeLlmEvent>) -> Self {
176 Self {
177 turns: vec![FakeLlmTurn::stream(events)],
178 }
179 }
180
181 pub fn erroring(category: ErrorCategory, message: impl Into<String>) -> Self {
183 Self {
184 turns: vec![FakeLlmTurn::error(category, message)],
185 }
186 }
187
188 pub fn push(mut self, turn: FakeLlmTurn) -> Self {
190 self.turns.push(turn);
191 self
192 }
193}
194
195#[derive(Clone, Debug)]
198pub struct FakeLlmCall {
199 pub provider: String,
200 pub model: String,
201 pub system: Option<String>,
202 pub messages: Vec<serde_json::Value>,
203 pub native_tools: Option<Vec<serde_json::Value>>,
204 pub stream: bool,
205}
206
207impl FakeLlmCall {
208 fn from_request(request: &LlmRequestPayload) -> Self {
209 Self {
210 provider: request.provider.clone(),
211 model: request.model.clone(),
212 system: request.system.clone(),
213 messages: request.messages.clone(),
214 native_tools: request.native_tools.clone(),
215 stream: request.stream,
216 }
217 }
218}
219
220thread_local! {
221 static FAKE_LLM_TURNS: RefCell<Vec<FakeLlmTurn>> = const { RefCell::new(Vec::new()) };
222 static FAKE_LLM_CALLS: RefCell<Vec<FakeLlmCall>> = const { RefCell::new(Vec::new()) };
223}
224
225#[must_use = "FakeLlmGuard asserts on drop; bind it to a `_guard` local"]
233pub fn install_fake_llm_script(script: FakeLlmScript) -> FakeLlmGuard {
234 FAKE_LLM_TURNS.with(|turns| {
235 let mut turns = turns.borrow_mut();
236 assert!(
237 turns.is_empty(),
238 "FakeLlmProvider: a script is already installed; drop the previous guard before installing a new one"
239 );
240 *turns = script.turns;
241 });
242 FAKE_LLM_CALLS.with(|calls| calls.borrow_mut().clear());
243 FakeLlmGuard { _priv: () }
244}
245
246pub fn fake_llm_captured_calls() -> Vec<FakeLlmCall> {
248 FAKE_LLM_CALLS.with(|calls| calls.borrow().clone())
249}
250
251#[must_use]
257pub struct FakeLlmGuard {
258 _priv: (),
259}
260
261impl Drop for FakeLlmGuard {
262 fn drop(&mut self) {
263 let remaining = FAKE_LLM_TURNS.with(|turns| std::mem::take(&mut *turns.borrow_mut()));
264 FAKE_LLM_CALLS.with(|calls| calls.borrow_mut().clear());
265 if std::thread::panicking() {
268 return;
269 }
270 assert!(
271 remaining.is_empty(),
272 "FakeLlmProvider script had {} unconsumed turn(s); did the code under test make fewer LLM calls than expected?",
273 remaining.len()
274 );
275 }
276}
277
278fn take_next_turn(request: &LlmRequestPayload) -> Result<FakeLlmTurn, VmError> {
281 FAKE_LLM_CALLS.with(|calls| {
282 calls.borrow_mut().push(FakeLlmCall::from_request(request));
283 });
284 FAKE_LLM_TURNS.with(|turns| {
285 let mut turns = turns.borrow_mut();
286 if turns.is_empty() {
287 Err(VmError::Runtime(
288 "FakeLlmProvider: no script installed (or script exhausted) — install_fake_llm_script() must precede harness.llm.call(provider: \"fake\")".to_string()
289 ))
290 } else {
291 Ok(turns.remove(0))
292 }
293 })
294}
295
296fn fake_error_to_vm_error(err: &FakeLlmError) -> VmError {
299 let message = match err.retry_after_ms {
300 Some(ms) => {
301 let secs = (ms as f64 / 1000.0).max(0.0);
302 let sep = if err.message.is_empty() || err.message.ends_with('\n') {
303 ""
304 } else {
305 "\n"
306 };
307 format!("{}{sep}retry-after: {secs}\n", err.message)
308 }
309 None => err.message.clone(),
310 };
311 VmError::CategorizedError {
312 message,
313 category: err.category.clone(),
314 }
315}
316
317pub(crate) struct FakeLlmProvider;
319
320impl LlmProvider for FakeLlmProvider {
321 fn name(&self) -> &'static str {
322 "fake"
323 }
324
325 fn requires_model(&self) -> bool {
326 false
327 }
328}
329
330impl LlmProviderChat for FakeLlmProvider {
331 fn chat<'a>(
332 &'a self,
333 request: &'a LlmRequestPayload,
334 delta_tx: Option<DeltaSender>,
335 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<LlmResult, VmError>> + 'a>> {
336 Box::pin(self.chat_impl(request, delta_tx))
337 }
338}
339
340impl FakeLlmProvider {
341 pub(crate) fn should_intercept(provider: &str) -> bool {
343 provider == "fake"
344 }
345
346 pub(crate) async fn chat_impl(
347 &self,
348 request: &LlmRequestPayload,
349 delta_tx: Option<DeltaSender>,
350 ) -> Result<LlmResult, VmError> {
351 loop {
352 let turn = take_next_turn(request)?;
353 match turn {
354 FakeLlmTurn::Stalled(duration) => {
355 if !duration.is_zero() {
356 tokio::time::sleep(duration).await;
357 }
358 }
362 FakeLlmTurn::Error(err) => {
363 return Err(fake_error_to_vm_error(&err));
364 }
365 FakeLlmTurn::Stream(events) => {
366 return play_stream(request, events, delta_tx).await;
367 }
368 }
369 }
370 }
371}
372
373async fn play_stream(
374 request: &LlmRequestPayload,
375 events: Vec<FakeLlmEvent>,
376 delta_tx: Option<DeltaSender>,
377) -> Result<LlmResult, VmError> {
378 let mut text = String::new();
379 let mut tool_calls: Vec<serde_json::Value> = Vec::new();
380 let mut blocks: Vec<serde_json::Value> = Vec::new();
381 let mut stop_reason: Option<FakeStopReason> = None;
382 let mut next_tool_index: usize = 1;
383 let mut schema_watch = crate::llm::api::StreamSchemaWatch::from_payload(request);
387
388 for event in events {
389 match event {
390 FakeLlmEvent::Token(chunk) => {
391 if let Some(tx) = delta_tx.as_ref() {
392 let _ = tx.send(chunk.clone());
393 }
394 text.push_str(&chunk);
395 if let Some(watch) = schema_watch.as_mut() {
396 if let Some(abort) = watch.observe(&chunk) {
397 return Err(abort.into_vm_error());
398 }
399 }
400 }
401 FakeLlmEvent::ToolCallDelta {
402 id,
403 name,
404 arguments,
405 } => {
406 let id = if id.is_empty() {
407 let auto = format!("fake_call_{next_tool_index}");
408 next_tool_index += 1;
409 auto
410 } else {
411 id
412 };
413 tool_calls.push(serde_json::json!({
414 "id": id,
415 "type": "tool_call",
416 "name": name,
417 "arguments": arguments,
418 }));
419 blocks.push(serde_json::json!({
420 "type": "tool_call",
421 "id": id,
422 "name": name,
423 "arguments": arguments,
424 "visibility": "internal",
425 }));
426 }
427 FakeLlmEvent::Stall(duration) => {
428 if !duration.is_zero() {
429 tokio::time::sleep(duration).await;
430 }
431 }
432 FakeLlmEvent::Error(error) => return Err(fake_error_to_vm_error(&error)),
433 FakeLlmEvent::Done(reason) => {
434 stop_reason = Some(reason);
435 break;
436 }
437 }
438 }
439
440 if !text.is_empty() {
441 let text_block = serde_json::json!({
444 "type": "output_text",
445 "text": text,
446 "visibility": "public",
447 });
448 blocks.insert(0, text_block);
449 }
450
451 let stop_reason = stop_reason.unwrap_or(if tool_calls.is_empty() {
452 FakeStopReason::EndTurn
453 } else {
454 FakeStopReason::ToolUse
455 });
456
457 Ok(LlmResult {
458 text_projection: None,
459 served_fast: false,
460 text,
461 raw_tool_calls: Vec::new(),
462 tool_calls,
463 input_tokens: count_input_tokens(&request.messages),
464 output_tokens: 0,
465 cache_read_tokens: 0,
466 cache_write_tokens: 0,
467 cache_supported: true,
468 model: request.model.clone(),
469 provider: "fake".to_string(),
470 thinking: None,
471 thinking_summary: None,
472 stop_reason: Some(stop_reason.as_str().to_string()),
473 blocks,
474 logprobs: Vec::new(),
475 telemetry: ProviderTelemetry::default(),
476 })
477}
478
479fn count_input_tokens(messages: &[serde_json::Value]) -> i64 {
483 fn collect(value: &serde_json::Value, out: &mut String) {
484 match value {
485 serde_json::Value::String(text) => {
486 out.push_str(text);
487 out.push('\n');
488 }
489 serde_json::Value::Array(items) => {
490 for item in items {
491 collect(item, out);
492 }
493 }
494 serde_json::Value::Object(map) => {
495 for value in map.values() {
496 collect(value, out);
497 }
498 }
499 _ => {}
500 }
501 }
502 let mut buf = String::new();
503 for message in messages {
504 collect(message, &mut buf);
505 }
506 buf.len() as i64
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::llm::api::{LlmApiMode, ThinkingConfig};
513 use crate::llm::api::{LlmRequestPayload, OutputFormat};
514
515 fn fake_request() -> LlmRequestPayload {
516 LlmRequestPayload {
517 provider: "fake".to_string(),
518 model: "fake-model".to_string(),
519 region: None,
520 api_key: String::new(),
521 api_mode: LlmApiMode::ChatCompletions,
522 messages: vec![serde_json::json!({"role": "user", "content": "hello"})],
523 system: None,
524 max_tokens: 64,
525 temperature: None,
526 top_p: None,
527 top_k: None,
528 logprobs: false,
529 top_logprobs: None,
530 stop: None,
531 seed: None,
532 frequency_penalty: None,
533 presence_penalty: None,
534 fast: false,
535 output_format: OutputFormat::Text,
536 response_format: None,
537 json_schema: None,
538 output_schema: None,
539 schema_stream_abort: false,
540 thinking: ThinkingConfig::Disabled,
541 anthropic_beta_features: Vec::new(),
542 vision: false,
543 native_tools: None,
544 provider_tools: Vec::new(),
545 tool_choice: None,
546 cache: false,
547 prompt_cache_ttl: None,
548 timeout: None,
549 idle_timeout: None,
550 stream: true,
551 provider_overrides: None,
552 previous_response_id: None,
553 store: None,
554 background: None,
555 truncation: None,
556 compact: None,
557 include: None,
558 max_tool_calls: None,
559 prefill: None,
560 session_id: None,
561 reminder_lifecycle: Vec::new(),
562 cli_llm_mock_scope: None,
563 mock_scope: None,
564 }
565 }
566
567 fn current_thread_runtime() -> tokio::runtime::Runtime {
568 tokio::runtime::Builder::new_current_thread()
569 .enable_all()
570 .start_paused(false)
571 .build()
572 .expect("runtime")
573 }
574
575 #[test]
576 fn streaming_turn_emits_deltas_in_order() {
577 let runtime = current_thread_runtime();
578 let _guard = install_fake_llm_script(FakeLlmScript::streaming(vec![
579 FakeLlmEvent::Token("hello ".into()),
580 FakeLlmEvent::Token("world".into()),
581 FakeLlmEvent::Done(FakeStopReason::EndTurn),
582 ]));
583
584 runtime.block_on(async {
585 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
586 let result = FakeLlmProvider
587 .chat_impl(&fake_request(), Some(tx))
588 .await
589 .expect("fake call should succeed");
590
591 let mut deltas = Vec::new();
592 while let Ok(delta) = rx.try_recv() {
593 deltas.push(delta);
594 }
595
596 assert_eq!(deltas, vec!["hello ".to_string(), "world".to_string()]);
597 assert_eq!(result.text, "hello world");
598 assert_eq!(result.provider, "fake");
599 assert_eq!(result.stop_reason.as_deref(), Some("end_turn"));
600 assert_eq!(result.blocks.len(), 1);
601 assert_eq!(result.blocks[0]["type"].as_str(), Some("output_text"));
602 assert_eq!(result.blocks[0]["text"].as_str(), Some("hello world"));
603 });
604 assert_eq!(fake_llm_captured_calls().len(), 1);
605 }
606
607 #[test]
608 fn tool_call_deltas_become_tool_calls_and_blocks() {
609 let runtime = current_thread_runtime();
610 let _guard = install_fake_llm_script(FakeLlmScript::streaming(vec![
611 FakeLlmEvent::Token("calling tool".into()),
612 FakeLlmEvent::ToolCallDelta {
613 id: String::new(),
614 name: "search".into(),
615 arguments: serde_json::json!({"q": "harn"}),
616 },
617 FakeLlmEvent::Done(FakeStopReason::ToolUse),
618 ]));
619
620 runtime.block_on(async {
621 let result = FakeLlmProvider
622 .chat_impl(&fake_request(), None)
623 .await
624 .expect("fake call should succeed");
625
626 assert_eq!(result.tool_calls.len(), 1);
627 assert_eq!(result.tool_calls[0]["name"].as_str(), Some("search"));
628 assert_eq!(result.tool_calls[0]["id"].as_str(), Some("fake_call_1"));
629 assert_eq!(
630 result.tool_calls[0]["arguments"]["q"].as_str(),
631 Some("harn")
632 );
633 assert_eq!(result.stop_reason.as_deref(), Some("tool_use"));
634 assert_eq!(result.blocks[0]["type"].as_str(), Some("output_text"));
636 assert_eq!(result.blocks[1]["type"].as_str(), Some("tool_call"));
637 assert_eq!(result.blocks[1]["name"].as_str(), Some("search"));
638 });
639 }
640
641 #[test]
642 fn error_turn_returns_categorized_error() {
643 let runtime = current_thread_runtime();
644 let _guard = install_fake_llm_script(FakeLlmScript::erroring(
645 ErrorCategory::RateLimit,
646 "throttled",
647 ));
648
649 runtime.block_on(async {
650 let err = FakeLlmProvider
651 .chat_impl(&fake_request(), None)
652 .await
653 .expect_err("fake error turn should fail");
654 match err {
655 VmError::CategorizedError { message, category } => {
656 assert_eq!(category, ErrorCategory::RateLimit);
657 assert!(
658 message.contains("throttled"),
659 "error message should pass through: {message}"
660 );
661 }
662 other => panic!("expected CategorizedError, got {other:?}"),
663 }
664 });
665 }
666
667 #[test]
668 fn error_turn_embeds_retry_after_hint() {
669 let runtime = current_thread_runtime();
670 let _guard = install_fake_llm_script(FakeLlmScript::default().push(FakeLlmTurn::Error(
671 FakeLlmError::new(ErrorCategory::RateLimit, "throttled").with_retry_after_ms(2_500),
672 )));
673
674 runtime.block_on(async {
675 let err = FakeLlmProvider
676 .chat_impl(&fake_request(), None)
677 .await
678 .expect_err("fake error turn should fail");
679 let VmError::CategorizedError { message, .. } = err else {
680 panic!("expected CategorizedError");
681 };
682 assert!(
683 message.contains("retry-after: 2.5"),
684 "retry-after hint should be present in synthetic message: {message}"
685 );
686 });
687 }
688
689 #[test]
690 fn stalled_turn_advances_under_paused_clock() {
691 let runtime = tokio::runtime::Builder::new_current_thread()
692 .enable_all()
693 .start_paused(true)
694 .build()
695 .expect("paused runtime");
696 let _guard = install_fake_llm_script(
697 FakeLlmScript::default()
698 .push(FakeLlmTurn::Stalled(Duration::from_mins(1)))
699 .push(FakeLlmTurn::stream(vec![
700 FakeLlmEvent::Token("done".into()),
701 FakeLlmEvent::Done(FakeStopReason::EndTurn),
702 ])),
703 );
704
705 runtime.block_on(async {
706 let request = fake_request();
707 let chat = FakeLlmProvider.chat_impl(&request, None);
708 tokio::pin!(chat);
709
710 let polled = futures::poll!(&mut chat);
714 assert!(
715 matches!(polled, std::task::Poll::Pending),
716 "fake provider should be parked on the stall"
717 );
718
719 tokio::time::advance(Duration::from_mins(1)).await;
720 let result = chat.await.expect("after advance, fake call resolves");
721 assert_eq!(result.text, "done");
722 });
723 }
724
725 #[test]
726 fn multiple_turns_consumed_in_fifo_order() {
727 let runtime = current_thread_runtime();
728 let _guard = install_fake_llm_script(
729 FakeLlmScript::default()
730 .push(FakeLlmTurn::stream(vec![
731 FakeLlmEvent::Token("first".into()),
732 FakeLlmEvent::Done(FakeStopReason::EndTurn),
733 ]))
734 .push(FakeLlmTurn::stream(vec![
735 FakeLlmEvent::Token("second".into()),
736 FakeLlmEvent::Done(FakeStopReason::EndTurn),
737 ])),
738 );
739
740 runtime.block_on(async {
741 let first = FakeLlmProvider
742 .chat_impl(&fake_request(), None)
743 .await
744 .expect("first call");
745 let second = FakeLlmProvider
746 .chat_impl(&fake_request(), None)
747 .await
748 .expect("second call");
749 assert_eq!(first.text, "first");
750 assert_eq!(second.text, "second");
751 });
752
753 let calls = fake_llm_captured_calls();
754 assert_eq!(calls.len(), 2);
755 assert!(calls.iter().all(|c| c.provider == "fake"));
756 }
757
758 #[test]
759 #[should_panic(expected = "no script installed")]
760 fn calling_without_script_panics_with_explanatory_error() {
761 let runtime = current_thread_runtime();
762 runtime
764 .block_on(async {
765 FakeLlmProvider
766 .chat_impl(&fake_request(), None)
767 .await
768 .map_err(|e| e.to_string())
769 })
770 .unwrap();
771 }
772
773 #[test]
774 #[should_panic(expected = "unconsumed turn")]
775 fn drop_guard_asserts_on_unused_turns() {
776 let guard =
777 install_fake_llm_script(FakeLlmScript::default().push(FakeLlmTurn::stream(vec![
778 FakeLlmEvent::Done(FakeStopReason::EndTurn),
779 ])));
780 drop(guard);
782 }
783}