1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, LazyLock, Mutex, MutexGuard};
6
7use super::api::{LlmResult, ProviderTelemetry, RawProviderToolCall};
8use super::mock_store::{MockQueue, QueueMatch};
9use crate::orchestration::ToolCallRecord;
10use crate::value::{ErrorCategory, VmError, VmValue};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LlmReplayMode {
15 Off,
16 Record,
17 Replay,
18}
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21enum CliLlmMockMode {
22 #[default]
23 Off,
24 Replay,
25 Record,
26}
27
28#[derive(Default)]
29struct CliLlmMockState {
30 mode: CliLlmMockMode,
31 queue: MockQueue,
32 recordings: Vec<LlmMock>,
33}
34
35static CLI_LLM_MOCK_NEXT_SCOPE: AtomicU64 = AtomicU64::new(1);
36static CLI_LLM_MOCK_SCOPES: LazyLock<Mutex<BTreeMap<u64, CliLlmMockState>>> =
37 LazyLock::new(|| Mutex::new(BTreeMap::new()));
38
39struct CliLlmMockLease(u64);
40
41impl Drop for CliLlmMockLease {
42 fn drop(&mut self) {
43 cli_llm_mock_scopes().remove(&self.0);
44 }
45}
46
47#[derive(Clone, Debug)]
52pub struct MockError {
53 pub category: ErrorCategory,
54 pub message: String,
55 pub status: Option<u16>,
56 pub kind: Option<String>,
57 pub reason: Option<String>,
58 pub retry_after_ms: Option<u64>,
63}
64
65impl MockError {
66 fn has_provider_envelope(&self) -> bool {
67 self.status.is_some() || self.kind.is_some() || self.reason.is_some()
68 }
69}
70
71pub(crate) fn build_mock_error(
72 category: Option<String>,
73 message: Option<String>,
74 status: Option<u16>,
75 kind: Option<String>,
76 reason: Option<String>,
77 retry_after_ms: Option<u64>,
78) -> Result<MockError, String> {
79 if retry_after_ms.is_some_and(|ms| ms > i64::MAX as u64) {
80 return Err("error.retry_after_ms must fit in a signed 64-bit integer".to_string());
81 }
82 let kind = match kind {
83 Some(value) if value.trim().is_empty() => None,
84 Some(value) => {
85 let normalized = value.trim().to_ascii_lowercase();
86 if super::api::LlmErrorKind::parse(&normalized).is_none() {
87 return Err(format!("unknown error kind `{value}`"));
88 }
89 Some(normalized)
90 }
91 None => None,
92 };
93 let reason = reason.and_then(|value| {
94 let trimmed = value.trim();
95 if trimmed.is_empty() {
96 None
97 } else {
98 Some(trimmed.to_string())
99 }
100 });
101 let category_was_provided = category.is_some();
102 let category = match category {
103 Some(value) if value.trim().is_empty() => {
104 return Err("error.category must not be empty".to_string());
105 }
106 Some(value) => {
107 let normalized = value.trim().to_ascii_lowercase();
108 let category = ErrorCategory::parse(&normalized);
109 if category.as_str() != normalized {
110 return Err(format!("unknown error category `{value}`"));
111 }
112 category
113 }
114 None => infer_mock_error_category(status, kind.as_deref(), reason.as_deref()),
115 };
116 if !category_was_provided && kind.is_none() && status.is_none() && reason.is_none() {
117 return Err(
118 "error.category is required unless error.status, error.kind, or error.reason is set"
119 .to_string(),
120 );
121 }
122 Ok(MockError {
123 category,
124 message: message.unwrap_or_else(|| {
125 default_mock_error_message(status, kind.as_deref(), reason.as_deref())
126 }),
127 status,
128 kind,
129 reason,
130 retry_after_ms,
131 })
132}
133
134pub(crate) fn validate_mock_error_status(status: i64) -> Result<u16, String> {
135 let status = u16::try_from(status)
136 .map_err(|_| "error.status must be an HTTP status code".to_string())?;
137 reqwest::StatusCode::from_u16(status)
138 .map_err(|_| "error.status must be an HTTP status code".to_string())?;
139 Ok(status)
140}
141
142fn infer_mock_error_category(
143 status: Option<u16>,
144 kind: Option<&str>,
145 reason: Option<&str>,
146) -> ErrorCategory {
147 if let Some(status) = status {
148 match status {
149 401 | 403 => return ErrorCategory::Auth,
150 404 | 410 => return ErrorCategory::NotFound,
151 408 | 504 | 522 | 524 => return ErrorCategory::Timeout,
152 429 => return ErrorCategory::RateLimit,
153 503 | 529 => return ErrorCategory::Overloaded,
154 500 | 502 => return ErrorCategory::ServerError,
155 _ => {}
156 }
157 }
158 if let Some(reason) = reason {
159 match reason {
160 "rate_limit" => return ErrorCategory::RateLimit,
161 "timeout" => return ErrorCategory::Timeout,
162 "network_error" | "transient_network" => return ErrorCategory::TransientNetwork,
163 "server_error" | "provider_error" | "provider_5xx" | "upstream_unavailable" => {
164 return ErrorCategory::ServerError;
165 }
166 "auth_failure" => return ErrorCategory::Auth,
167 "model_unavailable" => return ErrorCategory::NotFound,
168 _ => {}
169 }
170 }
171 if kind == Some("transient") {
172 return ErrorCategory::ServerError;
173 }
174 ErrorCategory::Generic
175}
176
177fn default_mock_error_message(
178 status: Option<u16>,
179 kind: Option<&str>,
180 reason: Option<&str>,
181) -> String {
182 match (status, kind, reason) {
183 (Some(status), Some(kind), Some(reason)) => {
184 format!("HTTP {status} mock LLM error ({kind}/{reason})")
185 }
186 (Some(status), _, Some(reason)) => format!("HTTP {status} mock LLM error ({reason})"),
187 (Some(status), _, _) => format!("HTTP {status} mock LLM error"),
188 (None, Some(kind), Some(reason)) => format!("mock LLM error ({kind}/{reason})"),
189 (None, Some(kind), None) => format!("mock LLM error ({kind})"),
190 (None, None, Some(reason)) => format!("mock LLM error ({reason})"),
191 (None, None, None) => String::new(),
192 }
193}
194
195pub const DEFAULT_MOCK_SCOPE: &str = "default";
200
201pub const SHARED_MOCK_SCOPE: &str = "shared";
206
207pub const KNOWN_MOCK_SCOPES: &[&str] = &[
212 DEFAULT_MOCK_SCOPE,
213 SHARED_MOCK_SCOPE,
214 "agent.main",
215 "agent.input_guardrail",
216 "agent.scope_classifier",
217 "compaction",
218 "completion.judge",
219 "step.judge",
220];
221
222#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct MockConsumptionReceipt {
228 pub requested_scope: String,
229 pub resolved_scope: String,
230 pub matched: bool,
231 pub id: String,
232 pub consume: String,
234 pub fell_through: bool,
235 pub remaining: usize,
236}
237
238impl MockConsumptionReceipt {
239 pub(crate) fn hit(
240 requested_scope: &str,
241 resolved_scope: &str,
242 mock: &LlmMock,
243 fell_through: bool,
244 remaining: usize,
245 ) -> Self {
246 Self {
247 requested_scope: requested_scope.to_string(),
248 resolved_scope: resolved_scope.to_string(),
249 matched: true,
250 id: mock.entry_id.clone(),
251 consume: consume_label(mock.sticky).to_string(),
252 fell_through,
253 remaining,
254 }
255 }
256
257 pub(crate) fn miss(requested_scope: &str, remaining: usize) -> Self {
258 Self {
259 requested_scope: requested_scope.to_string(),
260 resolved_scope: String::new(),
261 matched: false,
262 id: String::new(),
263 consume: String::new(),
264 fell_through: false,
265 remaining,
266 }
267 }
268}
269
270fn consume_label(sticky: bool) -> &'static str {
272 if sticky {
273 "sticky"
274 } else {
275 "once"
276 }
277}
278
279#[derive(Clone, Debug)]
280pub struct LlmMock {
281 pub text: String,
282 pub tool_calls: Vec<serde_json::Value>,
283 pub raw_tool_calls: Vec<RawProviderToolCall>,
284 pub match_pattern: Option<String>, pub scope: String,
289 pub entry_id: String,
293 pub sticky: bool,
297 pub input_tokens: Option<i64>,
298 pub output_tokens: Option<i64>,
299 pub cache_read_tokens: Option<i64>,
300 pub cache_write_tokens: Option<i64>,
301 pub thinking: Option<String>,
302 pub thinking_summary: Option<String>,
303 pub stop_reason: Option<String>,
304 pub model: String,
305 pub provider: Option<String>,
306 pub blocks: Option<Vec<serde_json::Value>>,
307 pub logprobs: Vec<serde_json::Value>,
308 pub error: Option<MockError>,
311 pub stream_chunks: Vec<String>,
318}
319
320#[derive(Clone, Debug, Default)]
325pub struct LlmMockFixture {
326 pub schema_version: u32,
327 pub strict_scopes: bool,
328 pub mocks: Vec<LlmMock>,
329 pub warnings: Vec<String>,
330}
331
332#[derive(Clone, Debug, PartialEq, Eq)]
335pub(crate) struct LlmMockFixtureReceipt {
336 pub schema_version: u32,
337 pub strict_scopes: bool,
338 pub count: usize,
339 pub scopes: Vec<String>,
340 pub warnings: Vec<String>,
341}
342
343pub const MAX_MOCK_SCHEMA_VERSION: u32 = 1;
345
346#[derive(Clone)]
347pub(crate) struct LlmMockCall {
348 pub mock_scope: String,
349 pub api_mode: String,
350 pub messages: Vec<serde_json::Value>,
351 pub system: Option<String>,
352 pub tools: Option<Vec<serde_json::Value>>,
353 pub provider_tools: Option<Vec<serde_json::Value>>,
354 pub tool_choice: Option<serde_json::Value>,
355 pub output_format: serde_json::Value,
356 pub thinking: serde_json::Value,
357 pub max_tokens: i64,
358 pub temperature: Option<f64>,
359 pub top_p: Option<f64>,
360 pub top_k: Option<i64>,
361 pub stop: Option<Vec<String>>,
362 pub seed: Option<i64>,
363 pub frequency_penalty: Option<f64>,
364 pub presence_penalty: Option<f64>,
365 pub previous_response_id: Option<String>,
366 pub store: Option<bool>,
367 pub background: Option<bool>,
368 pub truncation: Option<String>,
369 pub compact: Option<bool>,
370 pub include: Option<Vec<String>>,
371 pub max_tool_calls: Option<i64>,
372 pub prefill: Option<String>,
373}
374
375type LlmMockScope = (
376 MockQueue,
377 Vec<LlmMockCall>,
378 BTreeSet<String>,
379 Vec<MockConsumptionReceipt>,
380);
381
382#[derive(Default)]
383struct LlmMockState {
384 builtin_queue: MockQueue,
385 calls: Vec<LlmMockCall>,
386 prompt_cache: BTreeSet<String>,
387 scopes: Vec<LlmMockScope>,
388 receipts: Vec<MockConsumptionReceipt>,
389 cli_scope: Option<Arc<CliLlmMockLease>>,
390}
391
392#[derive(Clone, Default)]
399pub(crate) struct LlmMockContext(Arc<Mutex<LlmMockState>>);
400
401impl LlmMockContext {
402 pub(crate) fn for_new_vm() -> Self {
403 let context = Self::default();
404 context.lock().cli_scope = current_cli_llm_mock_lease();
405 context
406 }
407
408 fn lock(&self) -> MutexGuard<'_, LlmMockState> {
409 self.0
410 .lock()
411 .unwrap_or_else(|poisoned| poisoned.into_inner())
412 }
413}
414
415thread_local! {
416 static LLM_REPLAY_MODE: RefCell<LlmReplayMode> = const { RefCell::new(LlmReplayMode::Off) };
417 static LLM_FIXTURE_DIR: RefCell<String> = const { RefCell::new(String::new()) };
418 static TOOL_RECORDINGS: RefCell<Vec<ToolCallRecord>> = const { RefCell::new(Vec::new()) };
419 static LLM_MOCK_CONTEXT: RefCell<LlmMockContext> = RefCell::new(LlmMockContext::default());
420 static LLM_MOCK_STREAM_CHUNKS: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
425}
426
427pub(crate) fn swap_llm_mock_context(next: LlmMockContext) -> LlmMockContext {
428 LLM_MOCK_CONTEXT.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), next))
429}
430
431pub(crate) fn current_llm_mock_context() -> LlmMockContext {
432 LLM_MOCK_CONTEXT.with(|slot| slot.borrow().clone())
433}
434
435fn with_mock_state<T>(f: impl FnOnce(&LlmMockState) -> T) -> T {
436 let context = current_llm_mock_context();
437 let state = context.lock();
438 f(&state)
439}
440
441fn with_mock_state_mut<T>(f: impl FnOnce(&mut LlmMockState) -> T) -> T {
442 let context = current_llm_mock_context();
443 let mut state = context.lock();
444 f(&mut state)
445}
446
447pub(crate) fn set_mock_stream_chunks(chunks: Option<Vec<String>>) {
451 LLM_MOCK_STREAM_CHUNKS.with(|slot| *slot.borrow_mut() = chunks);
452}
453
454pub(crate) fn take_mock_stream_chunks() -> Option<Vec<String>> {
458 LLM_MOCK_STREAM_CHUNKS.with(|slot| slot.borrow_mut().take())
459}
460
461fn cli_llm_mock_scopes() -> MutexGuard<'static, BTreeMap<u64, CliLlmMockState>> {
462 CLI_LLM_MOCK_SCOPES
463 .lock()
464 .unwrap_or_else(|poisoned| poisoned.into_inner())
465}
466
467fn next_cli_llm_mock_scope_id() -> u64 {
468 CLI_LLM_MOCK_NEXT_SCOPE.fetch_add(1, Ordering::Relaxed)
469}
470
471pub(crate) fn current_cli_llm_mock_scope() -> Option<u64> {
472 current_cli_llm_mock_lease().map(|scope| scope.0)
473}
474
475fn current_cli_llm_mock_lease() -> Option<Arc<CliLlmMockLease>> {
476 with_mock_state(|state| state.cli_scope.clone())
477}
478
479fn install_cli_llm_mock_scope(state: CliLlmMockState) {
480 clear_cli_llm_mock_mode();
481 let scope = next_cli_llm_mock_scope_id();
482 cli_llm_mock_scopes().insert(scope, state);
483 with_mock_state_mut(|state| state.cli_scope = Some(Arc::new(CliLlmMockLease(scope))));
484}
485
486#[cfg(test)]
489pub(crate) fn push_llm_mock(mock: LlmMock) {
490 with_mock_state_mut(|state| state.builtin_queue.push_v0(mock));
491}
492
493pub(crate) fn push_inline_llm_mock(mock: LlmMock) -> Result<(), String> {
497 with_mock_state_mut(|state| {
498 let queue = &mut state.builtin_queue;
499 if queue.schema_version() > 0 {
500 return Err(
501 "cannot append harness.llm.mock_enqueue() entries to an active versioned fixture; clear or load one complete document"
502 .to_string(),
503 );
504 }
505 queue.push_v0(mock);
506 Ok(())
507 })
508}
509
510pub(crate) fn install_builtin_llm_mock_fixture(fixture: LlmMockFixture) -> LlmMockFixtureReceipt {
514 let queue = MockQueue::from_fixture(fixture);
515 let receipt = LlmMockFixtureReceipt {
516 schema_version: queue.schema_version(),
517 strict_scopes: queue.strict_scopes(),
518 count: queue.count(),
519 scopes: queue.scopes(),
520 warnings: queue.warnings().to_vec(),
521 };
522 with_mock_state_mut(|state| state.builtin_queue = queue);
523 receipt
524}
525
526pub(crate) fn get_llm_mock_calls() -> Vec<LlmMockCall> {
527 with_mock_state(|state| state.calls.clone())
528}
529
530pub(crate) fn get_llm_mock_receipts() -> Vec<MockConsumptionReceipt> {
532 with_mock_state(|state| state.receipts.clone())
533}
534
535fn record_mock_receipt(session_id: Option<&str>, receipt: MockConsumptionReceipt) {
536 if receipt.matched {
537 if let Some(session_id) = session_id.filter(|id| !id.is_empty()) {
538 super::agent_runtime::emit_agent_event_sync(
539 &crate::agent_events::AgentEvent::TypedCheckpoint {
540 session_id: session_id.to_string(),
541 checkpoint: serde_json::json!({
542 "kind": "llm_mock_fixture_consumption",
543 "schema": "harn.llm_mock_fixture_consumption.v1",
544 "id": receipt.id,
545 "requested_scope": receipt.requested_scope,
546 "resolved_scope": receipt.resolved_scope,
547 "consume": receipt.consume,
548 "fell_through": receipt.fell_through,
549 "remaining": receipt.remaining,
550 }),
551 },
552 );
553 }
554 }
555 with_mock_state_mut(|state| state.receipts.push(receipt));
556}
557
558pub(crate) fn builtin_llm_mock_snapshot() -> serde_json::Value {
559 with_mock_state(|state| {
560 let queue = &state.builtin_queue;
561 serde_json::json!({
562 "schema": "harn.llm_mock_fixture_queue.v1",
563 "schema_version": queue.schema_version(),
564 "strict_scopes": queue.strict_scopes(),
565 "queue_remaining": queue.queue_remaining(),
566 "warnings": queue.warnings(),
567 })
568 })
569}
570
571pub(crate) fn builtin_llm_mock_active() -> bool {
572 with_mock_state(|state| state.builtin_queue.is_active())
573}
574
575pub(crate) fn builtin_llm_mock_strict_scopes() -> bool {
576 with_mock_state(|state| state.builtin_queue.strict_scopes())
577}
578
579pub(crate) fn reset_llm_mock_state() {
580 with_mock_state_mut(|state| {
581 state.cli_scope = None;
582 state.builtin_queue = MockQueue::default();
583 state.calls.clear();
584 state.prompt_cache.clear();
585 state.scopes.clear();
586 state.receipts.clear();
587 });
588}
589
590pub(crate) fn push_llm_mock_scope() {
595 with_mock_state_mut(|state| {
596 let fixture = std::mem::take(&mut state.builtin_queue);
597 let calls = std::mem::take(&mut state.calls);
598 let cache = std::mem::take(&mut state.prompt_cache);
599 let receipts = std::mem::take(&mut state.receipts);
600 state.scopes.push((fixture, calls, cache, receipts));
601 });
602}
603
604pub(crate) fn pop_llm_mock_scope() -> bool {
610 with_mock_state_mut(|state| match state.scopes.pop() {
611 Some((fixture, calls, cache, receipts)) => {
612 state.builtin_queue = fixture;
613 state.calls = calls;
614 state.prompt_cache = cache;
615 state.receipts = receipts;
616 true
617 }
618 None => false,
619 })
620}
621
622pub fn clear_cli_llm_mock_mode() {
623 with_mock_state_mut(|state| state.cli_scope = None);
624}
625
626pub fn install_cli_llm_mocks(mocks: Vec<LlmMock>) {
627 install_cli_llm_mock_scope(CliLlmMockState {
628 mode: CliLlmMockMode::Replay,
629 queue: MockQueue::from_fixture(LlmMockFixture {
630 schema_version: 0,
631 strict_scopes: false,
632 mocks,
633 warnings: Vec::new(),
634 }),
635 recordings: Vec::new(),
636 });
637}
638
639pub fn install_cli_llm_mock_fixture(fixture: LlmMockFixture) {
641 install_cli_llm_mock_scope(CliLlmMockState {
642 mode: CliLlmMockMode::Replay,
643 queue: MockQueue::from_fixture(fixture),
644 recordings: Vec::new(),
645 });
646}
647
648pub fn enable_cli_llm_mock_recording() {
649 install_cli_llm_mock_scope(CliLlmMockState {
650 mode: CliLlmMockMode::Record,
651 queue: MockQueue::default(),
652 recordings: Vec::new(),
653 });
654}
655
656pub fn take_cli_llm_recordings() -> Vec<LlmMock> {
657 let Some(scope) = current_cli_llm_mock_scope() else {
658 return Vec::new();
659 };
660 cli_llm_mock_scopes()
661 .get_mut(&scope)
662 .map(|state| std::mem::take(&mut state.recordings))
663 .unwrap_or_default()
664}
665
666pub(crate) fn cli_llm_mock_replay_active() -> bool {
667 cli_llm_mock_replay_active_for_scope(current_cli_llm_mock_scope())
668}
669
670pub(crate) fn cli_llm_mock_replay_active_for_scope(scope: Option<u64>) -> bool {
671 let Some(scope) = scope else {
672 return false;
673 };
674 cli_llm_mock_scopes()
675 .get(&scope)
676 .is_some_and(|state| state.mode == CliLlmMockMode::Replay)
677}
678
679fn record_llm_mock_call(request: &super::api::LlmRequestPayload) {
680 with_mock_state_mut(|state| {
681 state.calls.push(LlmMockCall {
682 mock_scope: request
683 .mock_scope
684 .as_deref()
685 .unwrap_or(DEFAULT_MOCK_SCOPE)
686 .to_string(),
687 api_mode: request.api_mode.as_str().to_string(),
688 messages: request.messages.clone(),
689 system: request.system.clone(),
690 tools: request.native_tools.clone(),
691 provider_tools: if request.provider_tools.is_empty() {
692 None
693 } else {
694 Some(request.provider_tools.clone())
695 },
696 tool_choice: request.tool_choice.clone(),
697 output_format: serde_json::to_value(&request.output_format).unwrap_or_else(|_| {
698 serde_json::json!({
699 "kind": "text"
700 })
701 }),
702 thinking: serde_json::to_value(&request.thinking).unwrap_or_else(|_| {
703 serde_json::json!({
704 "mode": "disabled"
705 })
706 }),
707 max_tokens: request.max_tokens,
708 temperature: request.temperature,
709 top_p: request.top_p,
710 top_k: request.top_k,
711 stop: request.stop.clone(),
712 seed: request.seed,
713 frequency_penalty: request.frequency_penalty,
714 presence_penalty: request.presence_penalty,
715 previous_response_id: request.previous_response_id.clone(),
716 store: request.store,
717 background: request.background,
718 truncation: request.truncation.clone(),
719 compact: request.compact,
720 include: request.include.clone(),
721 max_tool_calls: request.max_tool_calls,
722 prefill: request.prefill.clone(),
723 });
724 });
725}
726
727fn build_mock_result(mock: &LlmMock, last_msg_len: usize) -> LlmResult {
729 let effective_text = if !mock.stream_chunks.is_empty() && mock.text.is_empty() {
734 mock.stream_chunks.concat()
735 } else {
736 mock.text.clone()
737 };
738 set_mock_stream_chunks(if mock.stream_chunks.is_empty() {
739 None
740 } else {
741 Some(mock.stream_chunks.clone())
742 });
743 let mock = &LlmMock {
744 text: effective_text,
745 ..mock.clone()
746 };
747 let (tool_calls, blocks) = if let Some(blocks) = &mock.blocks {
748 (mock.tool_calls.clone(), blocks.clone())
749 } else {
750 let mut blocks = Vec::new();
751
752 if !mock.text.is_empty() {
753 blocks.push(serde_json::json!({
754 "type": "output_text",
755 "text": mock.text,
756 "visibility": "public",
757 }));
758 }
759
760 let mut tool_calls = Vec::new();
761 for (i, tc) in mock.tool_calls.iter().enumerate() {
762 let id = format!("mock_call_{}", i + 1);
763 let name = tc.get("name").and_then(|n| n.as_str()).unwrap_or("unknown");
764 let arguments = tc
765 .get("arguments")
766 .cloned()
767 .unwrap_or(serde_json::json!({}));
768 tool_calls.push(serde_json::json!({
769 "id": id,
770 "type": "tool_call",
771 "name": name,
772 "arguments": arguments,
773 }));
774 blocks.push(serde_json::json!({
775 "type": "tool_call",
776 "id": id,
777 "name": name,
778 "arguments": arguments,
779 "visibility": "internal",
780 }));
781 }
782
783 (tool_calls, blocks)
784 };
785
786 LlmResult {
787 attempts: Default::default(),
788 text_projection: None,
789 served_fast: false,
790 text: mock.text.clone(),
791 raw_tool_calls: if mock.raw_tool_calls.is_empty() {
792 Vec::new()
793 } else {
794 mock.raw_tool_calls.clone()
795 },
796 tool_calls,
797 input_tokens: mock.input_tokens.unwrap_or(last_msg_len as i64),
798 output_tokens: mock.output_tokens.unwrap_or(30),
799 cache_read_tokens: mock.cache_read_tokens.unwrap_or(0),
800 cache_write_tokens: mock.cache_write_tokens.unwrap_or(0),
801 cache_supported: true,
802 model: mock.model.clone(),
803 provider: mock.provider.clone().unwrap_or_else(|| "mock".to_string()),
804 thinking: mock.thinking.clone(),
805 thinking_summary: mock.thinking_summary.clone(),
806 stop_reason: mock.stop_reason.clone(),
807 blocks,
808 logprobs: mock.logprobs.clone(),
809 telemetry: ProviderTelemetry::default(),
810 }
811}
812
813fn collect_mock_match_strings(value: &serde_json::Value, out: &mut Vec<String>) {
817 match value {
818 serde_json::Value::String(text) if !text.is_empty() => out.push(text.clone()),
819 serde_json::Value::String(_) => {}
820 serde_json::Value::Array(items) => {
821 for item in items {
822 collect_mock_match_strings(item, out);
823 }
824 }
825 serde_json::Value::Object(map) => {
826 for value in map.values() {
827 collect_mock_match_strings(value, out);
828 }
829 }
830 _ => {}
831 }
832}
833
834fn mock_match_text(messages: &[serde_json::Value]) -> String {
835 let mut parts = Vec::new();
836 for message in messages {
837 collect_mock_match_strings(message, &mut parts);
838 }
839 parts.join("\n")
840}
841
842fn mock_last_prompt_text(messages: &[serde_json::Value]) -> String {
843 for message in messages.iter().rev() {
844 let Some(content) = message.get("content") else {
845 continue;
846 };
847 let mut parts = Vec::new();
848 collect_mock_match_strings(content, &mut parts);
849 let text = parts.join("\n");
850 if !text.trim().is_empty() {
851 return text;
852 }
853 }
854 String::new()
855}
856
857fn mock_prompt_cache_key(
858 model: &str,
859 messages: &[serde_json::Value],
860 system: Option<&str>,
861 mock_scope: &str,
862) -> String {
863 serde_json::to_string(&serde_json::json!({
864 "model": model,
865 "system": system,
866 "messages": messages,
867 "mock_scope": mock_scope,
868 }))
869 .unwrap_or_default()
870}
871
872fn apply_mock_prompt_cache(result: &mut LlmResult, cache_key: &str) {
873 if result.cache_read_tokens > 0 || result.cache_write_tokens > 0 {
874 return;
875 }
876 let cache_tokens = result.input_tokens.max(0);
877 if cache_tokens == 0 {
878 return;
879 }
880 let cache_hit = with_mock_state_mut(|state| {
881 if state.prompt_cache.contains(cache_key) {
882 true
883 } else {
884 state.prompt_cache.insert(cache_key.to_string());
885 false
886 }
887 });
888 if cache_hit {
889 result.cache_read_tokens = cache_tokens;
890 } else {
891 result.cache_write_tokens = cache_tokens;
892 }
893}
894
895fn mock_error_to_vm_error(err: &MockError) -> VmError {
899 let message = mock_error_message(err);
900 if err.has_provider_envelope() {
901 let classified = super::api::classify_llm_error(err.category.clone(), &message);
902 let mut dict = BTreeMap::new();
903 dict.put_str("category", err.category.as_str());
904 dict.put_str(
905 "kind",
906 err.kind
907 .as_deref()
908 .unwrap_or_else(|| classified.kind.as_str()),
909 );
910 dict.put_str(
911 "reason",
912 err.reason
913 .as_deref()
914 .unwrap_or_else(|| classified.reason.as_str()),
915 );
916 dict.put_str("message", message);
917 if let Some(status) = err.status {
918 dict.insert("status".to_string(), VmValue::Int(i64::from(status)));
919 }
920 if let Some(retry_after_ms) = err.retry_after_ms {
921 dict.insert(
922 "retry_after_ms".to_string(),
923 VmValue::Int(retry_after_ms as i64),
924 );
925 }
926 return VmError::Thrown(VmValue::dict(dict));
927 }
928
929 VmError::CategorizedError {
930 message,
931 category: err.category.clone(),
932 }
933}
934
935fn mock_error_message(err: &MockError) -> String {
936 let Some(ms) = err.retry_after_ms else {
940 return err.message.clone();
941 };
942 if err.has_provider_envelope() {
943 return err.message.clone();
944 }
945 let secs = (ms as f64 / 1000.0).max(0.0);
946 let sep = if err.message.is_empty() || err.message.ends_with('\n') {
947 ""
948 } else {
949 "\n"
950 };
951 format!("{}{sep}retry-after: {secs}\n", err.message)
952}
953
954struct ScopedMatch {
957 outcome: Result<LlmResult, VmError>,
958 receipt: MockConsumptionReceipt,
959}
960
961fn build_scoped_match(selected: QueueMatch, match_text: &str) -> ScopedMatch {
962 let QueueMatch { mock, receipt } = selected;
963 let outcome = match &mock.error {
964 Some(err) => Err(mock_error_to_vm_error(err)),
965 None => Ok(build_mock_result(&mock, match_text.len())),
966 };
967 ScopedMatch { outcome, receipt }
968}
969
970fn try_match_builtin_mock(scope: &str, match_text: &str) -> Option<ScopedMatch> {
971 with_mock_state_mut(|state| {
972 state
973 .builtin_queue
974 .match_request(scope, match_text)
975 .map(|selected| build_scoped_match(selected, match_text))
976 })
977}
978
979fn try_match_cli_mock(
980 cli_scope: Option<u64>,
981 scope: &str,
982 match_text: &str,
983) -> Option<ScopedMatch> {
984 let cli_scope = cli_scope?;
985 let mut scopes = cli_llm_mock_scopes();
986 let state = scopes.get_mut(&cli_scope)?;
987 if state.mode != CliLlmMockMode::Replay {
988 return None;
989 }
990 state
991 .queue
992 .match_request(scope, match_text)
993 .map(|selected| build_scoped_match(selected, match_text))
994}
995
996pub(crate) fn record_cli_llm_result(request: &super::api::LlmRequestPayload, result: &LlmResult) {
997 record_unified_tape_llm_call(result);
998 let Some(scope) = request.cli_llm_mock_scope else {
999 return;
1000 };
1001 let mut scopes = cli_llm_mock_scopes();
1002 let Some(state) = scopes.get_mut(&scope) else {
1003 return;
1004 };
1005 if state.mode != CliLlmMockMode::Record {
1006 return;
1007 }
1008 state.recordings.push(LlmMock {
1009 text: result.text.clone(),
1010 tool_calls: result.tool_calls.clone(),
1011 raw_tool_calls: result.raw_tool_calls.clone(),
1012 match_pattern: None,
1013 scope: request
1014 .mock_scope
1015 .clone()
1016 .unwrap_or_else(|| DEFAULT_MOCK_SCOPE.to_string()),
1017 entry_id: String::new(),
1018 sticky: false,
1019 input_tokens: Some(result.input_tokens),
1020 output_tokens: Some(result.output_tokens),
1021 cache_read_tokens: Some(result.cache_read_tokens),
1022 cache_write_tokens: Some(result.cache_write_tokens),
1023 thinking: result.thinking.clone(),
1024 thinking_summary: result.thinking_summary.clone(),
1025 stop_reason: result.stop_reason.clone(),
1026 model: result.model.clone(),
1027 provider: Some(result.provider.clone()),
1028 blocks: Some(result.blocks.clone()),
1029 logprobs: result.logprobs.clone(),
1030 error: None,
1031 stream_chunks: Vec::new(),
1032 });
1033}
1034
1035fn record_unified_tape_llm_call(result: &LlmResult) {
1042 if crate::testbench::tape::active_recorder().is_none() {
1043 return;
1044 }
1045 let response_json = serde_json::to_vec(result).unwrap_or_else(|_| Vec::new());
1046 let request_digest = with_mock_state(|state| state.calls.last().cloned())
1047 .map(|call| {
1048 let mut request = serde_json::Map::new();
1049 request.insert("messages".to_string(), serde_json::json!(call.messages));
1050 request.insert("system".to_string(), serde_json::json!(call.system));
1051 request.insert("tools".to_string(), serde_json::json!(call.tools));
1052 request.insert(
1053 "tool_choice".to_string(),
1054 serde_json::json!(call.tool_choice),
1055 );
1056 request.insert("thinking".to_string(), serde_json::json!(call.thinking));
1057 if call.mock_scope != DEFAULT_MOCK_SCOPE {
1058 request.insert("mock_scope".to_string(), serde_json::json!(call.mock_scope));
1059 }
1060 request.insert("model".to_string(), serde_json::json!(result.model));
1061 if call.api_mode != "chat_completions" {
1062 request.insert("api_mode".to_string(), serde_json::json!(call.api_mode));
1063 }
1064 if call.provider_tools.is_some() {
1065 request.insert(
1066 "provider_tools".to_string(),
1067 serde_json::json!(call.provider_tools),
1068 );
1069 }
1070 if call
1071 .output_format
1072 .get("kind")
1073 .and_then(|value| value.as_str())
1074 != Some("text")
1075 {
1076 request.insert(
1077 "output_format".to_string(),
1078 serde_json::json!(call.output_format),
1079 );
1080 }
1081 if call.previous_response_id.is_some() {
1082 request.insert(
1083 "previous_response_id".to_string(),
1084 serde_json::json!(call.previous_response_id),
1085 );
1086 }
1087 if call.store.is_some() {
1088 request.insert("store".to_string(), serde_json::json!(call.store));
1089 }
1090 if call.background.is_some() {
1091 request.insert("background".to_string(), serde_json::json!(call.background));
1092 }
1093 if call.truncation.is_some() {
1094 request.insert("truncation".to_string(), serde_json::json!(call.truncation));
1095 }
1096 if call.compact.is_some() {
1097 request.insert("compact".to_string(), serde_json::json!(call.compact));
1098 }
1099 if call.include.is_some() {
1100 request.insert("include".to_string(), serde_json::json!(call.include));
1101 }
1102 if call.max_tool_calls.is_some() {
1103 request.insert(
1104 "max_tool_calls".to_string(),
1105 serde_json::json!(call.max_tool_calls),
1106 );
1107 }
1108 if call.prefill.is_some() {
1109 request.insert("prefill".to_string(), serde_json::json!(call.prefill));
1110 }
1111 let serialized = crate::canonical_json::to_vec(&serde_json::Value::Object(request));
1112 crate::testbench::tape::content_hash(&serialized)
1113 })
1114 .unwrap_or_else(|| {
1115 crate::testbench::tape::content_hash(result.text.as_bytes())
1118 });
1119 crate::testbench::tape::with_active_recorder(|recorder| {
1120 let response = recorder.payload_from_bytes(response_json);
1121 Some(crate::testbench::tape::TapeRecordKind::LlmCall {
1122 request_digest,
1123 response,
1124 })
1125 });
1126}
1127
1128fn unmatched_cli_prompt_error(match_text: &str) -> VmError {
1129 let mut snippet: String = match_text.chars().take(200).collect();
1130 if match_text.chars().count() > 200 {
1131 snippet.push_str("...");
1132 }
1133 VmError::Runtime(format!("No --llm-mock fixture matched prompt: {snippet:?}"))
1134}
1135
1136fn unmatched_builtin_prompt_error(match_text: &str) -> VmError {
1137 let mut snippet: String = match_text.chars().take(200).collect();
1138 if match_text.chars().count() > 200 {
1139 snippet.push_str("...");
1140 }
1141 VmError::Runtime(format!(
1142 "No llm_mock fixture matched prompt in a strict scope: {snippet:?}"
1143 ))
1144}
1145
1146pub fn set_replay_mode(mode: LlmReplayMode, fixture_dir: &str) {
1148 LLM_REPLAY_MODE.with(|v| *v.borrow_mut() = mode);
1149 LLM_FIXTURE_DIR.with(|v| *v.borrow_mut() = fixture_dir.to_string());
1150}
1151
1152pub(crate) fn get_replay_mode() -> LlmReplayMode {
1153 LLM_REPLAY_MODE.with(|v| *v.borrow())
1154}
1155
1156pub(crate) fn get_fixture_dir() -> String {
1157 LLM_FIXTURE_DIR.with(|v| v.borrow().clone())
1158}
1159
1160pub(crate) fn fixture_hash(
1162 model: &str,
1163 messages: &[serde_json::Value],
1164 system: Option<&str>,
1165 mock_scope: Option<&str>,
1166) -> String {
1167 use std::hash::{Hash, Hasher};
1168 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1169 model.hash(&mut hasher);
1170 serde_json::to_string(messages)
1172 .unwrap_or_default()
1173 .hash(&mut hasher);
1174 system.hash(&mut hasher);
1175 if mock_scope.is_some_and(|scope| scope != DEFAULT_MOCK_SCOPE) {
1176 mock_scope.hash(&mut hasher);
1177 }
1178 format!("{:016x}", hasher.finish())
1179}
1180
1181pub(crate) fn fixture_hash_for_request(request: &super::api::LlmRequestPayload) -> String {
1182 fixture_hash(
1183 &request.model,
1184 &request.messages,
1185 request.system.as_deref(),
1186 request.mock_scope.as_deref(),
1187 )
1188}
1189
1190pub(crate) fn save_fixture(hash: &str, result: &LlmResult) {
1191 let dir = get_fixture_dir();
1192 if dir.is_empty() {
1193 return;
1194 }
1195 let _ = std::fs::create_dir_all(&dir);
1196 let path = format!("{dir}/{hash}.json");
1197 let json = serde_json::json!({
1198 "text": result.text,
1199 "tool_calls": result.tool_calls,
1200 "raw_tool_calls": result.raw_tool_calls,
1201 "input_tokens": result.input_tokens,
1202 "output_tokens": result.output_tokens,
1203 "cache_read_tokens": result.cache_read_tokens,
1204 "cache_write_tokens": result.cache_write_tokens,
1205 "model": result.model,
1206 "provider": result.provider,
1207 "thinking": result.thinking,
1208 "thinking_summary": result.thinking_summary,
1209 "stop_reason": result.stop_reason,
1210 "blocks": result.blocks,
1211 "logprobs": result.logprobs,
1212 });
1213 let _ = std::fs::write(
1214 &path,
1215 serde_json::to_string_pretty(&json).unwrap_or_default(),
1216 );
1217}
1218
1219pub(crate) fn load_fixture(hash: &str) -> Option<LlmResult> {
1220 let dir = get_fixture_dir();
1221 if dir.is_empty() {
1222 return None;
1223 }
1224 let path = format!("{dir}/{hash}.json");
1225 let content = std::fs::read_to_string(&path).ok()?;
1226 let json: serde_json::Value = serde_json::from_str(&content).ok()?;
1227 Some(LlmResult {
1228 attempts: Default::default(),
1229 text_projection: None,
1230 served_fast: false,
1231 text: json["text"].as_str().unwrap_or("").to_string(),
1232 tool_calls: json["tool_calls"].as_array().cloned().unwrap_or_default(),
1233 raw_tool_calls: RawProviderToolCall::array_from_value(&json["raw_tool_calls"]).ok()?,
1234 input_tokens: json["input_tokens"].as_i64().unwrap_or(0),
1235 output_tokens: json["output_tokens"].as_i64().unwrap_or(0),
1236 cache_read_tokens: json["cache_read_tokens"].as_i64().unwrap_or(0),
1237 cache_write_tokens: json["cache_write_tokens"]
1238 .as_i64()
1239 .or_else(|| json["cache_creation_input_tokens"].as_i64())
1240 .unwrap_or(0),
1241 cache_supported: json["cache_supported"].as_bool().unwrap_or(true),
1242 model: json["model"].as_str().unwrap_or("").to_string(),
1243 provider: json["provider"].as_str().unwrap_or("mock").to_string(),
1244 thinking: json["thinking"].as_str().map(|s| s.to_string()),
1245 thinking_summary: json["thinking_summary"].as_str().map(|s| s.to_string()),
1246 stop_reason: json["stop_reason"].as_str().map(|s| s.to_string()),
1247 blocks: json["blocks"].as_array().cloned().unwrap_or_default(),
1248 logprobs: json["logprobs"].as_array().cloned().unwrap_or_default(),
1249 telemetry: serde_json::from_value(json["telemetry"].clone()).unwrap_or_default(),
1250 })
1251}
1252
1253fn mock_required_args(tool_schema: &serde_json::Value) -> serde_json::Value {
1257 let mut args = serde_json::Map::new();
1258 let input_schema = tool_schema
1262 .get("input_schema")
1263 .or_else(|| tool_schema.get("inputSchema"))
1264 .or_else(|| {
1265 tool_schema
1266 .get("function")
1267 .and_then(|f| f.get("parameters"))
1268 })
1269 .or_else(|| tool_schema.get("parameters"));
1270 let Some(schema) = input_schema else {
1271 return serde_json::Value::Object(args);
1272 };
1273 let required: std::collections::BTreeSet<String> = schema
1274 .get("required")
1275 .and_then(|r| r.as_array())
1276 .map(|arr| {
1277 arr.iter()
1278 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1279 .collect()
1280 })
1281 .unwrap_or_default();
1282 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
1283 for (name, prop) in props {
1284 if !required.contains(name) {
1285 continue;
1286 }
1287 let ty = prop
1288 .get("type")
1289 .and_then(|t| t.as_str())
1290 .unwrap_or("string");
1291 let placeholder = match ty {
1292 "integer" => serde_json::json!(0),
1293 "number" => serde_json::json!(0.0),
1294 "boolean" => serde_json::json!(false),
1295 "array" => serde_json::json!([]),
1296 "object" => serde_json::json!({}),
1297 _ => serde_json::json!(""),
1298 };
1299 args.insert(name.clone(), placeholder);
1300 }
1301 }
1302 serde_json::Value::Object(args)
1303}
1304
1305fn mock_tool_name(tool: &serde_json::Value) -> Option<&str> {
1306 tool.get("name")
1307 .or_else(|| {
1308 tool.get("function")
1309 .and_then(|function| function.get("name"))
1310 })
1311 .and_then(|name| name.as_str())
1312}
1313
1314fn mock_auto_tool_candidate(tools: &[serde_json::Value]) -> Option<&serde_json::Value> {
1315 tools
1316 .iter()
1317 .find(|tool| mock_tool_name(tool) != Some("agent_await_resumption"))
1318}
1319
1320pub(crate) fn mock_llm_response(
1325 request: &super::api::LlmRequestPayload,
1326) -> Result<LlmResult, VmError> {
1327 record_llm_mock_call(request);
1328 set_mock_stream_chunks(None);
1331
1332 let messages = &request.messages;
1333 let system = request.system.as_deref();
1334 let match_text = mock_match_text(messages);
1335 let prompt_text = mock_last_prompt_text(messages);
1336 let requested_scope = request.mock_scope.as_deref().unwrap_or(DEFAULT_MOCK_SCOPE);
1337 let cache_key = mock_prompt_cache_key(&request.model, messages, system, requested_scope);
1338
1339 if let Some(matched) =
1340 try_match_cli_mock(request.cli_llm_mock_scope, requested_scope, &match_text)
1341 {
1342 record_mock_receipt(request.session_id.as_deref(), matched.receipt);
1343 return matched.outcome.map(|mut result| {
1344 if request.cache {
1345 apply_mock_prompt_cache(&mut result, &cache_key);
1346 }
1347 result
1348 });
1349 }
1350
1351 if let Some(matched) = try_match_builtin_mock(requested_scope, &match_text) {
1352 record_mock_receipt(request.session_id.as_deref(), matched.receipt);
1353 return matched.outcome.map(|mut result| {
1354 if request.cache {
1355 apply_mock_prompt_cache(&mut result, &cache_key);
1356 }
1357 result
1358 });
1359 }
1360
1361 if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) || builtin_llm_mock_active()
1364 {
1365 let receipt = if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) {
1366 let scopes = cli_llm_mock_scopes();
1367 scopes
1368 .get(&request.cli_llm_mock_scope.unwrap())
1369 .map(|state| state.queue.miss_receipt(requested_scope))
1370 .unwrap_or_else(|| MockConsumptionReceipt::miss(requested_scope, 0))
1371 } else {
1372 with_mock_state(|state| state.builtin_queue.miss_receipt(requested_scope))
1373 };
1374 record_mock_receipt(request.session_id.as_deref(), receipt);
1375 }
1376
1377 if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) {
1378 return Err(unmatched_cli_prompt_error(&match_text));
1379 }
1380 if builtin_llm_mock_strict_scopes() {
1381 return Err(unmatched_builtin_prompt_error(&match_text));
1382 }
1383
1384 if let Some(tools) = request.native_tools.as_deref() {
1387 if let Some(first_tool) = mock_auto_tool_candidate(tools) {
1388 let tool_name = mock_tool_name(first_tool).unwrap_or("unknown");
1389 let mock_args = mock_required_args(first_tool);
1390 let mut result = LlmResult {
1391 attempts: Default::default(),
1392 text_projection: None,
1393 served_fast: false,
1394 text: String::new(),
1395 tool_calls: vec![serde_json::json!({
1396 "id": "mock_call_1",
1397 "type": "tool_call",
1398 "name": tool_name,
1399 "arguments": mock_args
1400 })],
1401 raw_tool_calls: Vec::new(),
1402 input_tokens: prompt_text.len() as i64,
1403 output_tokens: 20,
1404 cache_read_tokens: 0,
1405 cache_write_tokens: 0,
1406 cache_supported: true,
1407 model: request.model.clone(),
1408 provider: "mock".to_string(),
1409 thinking: None,
1410 thinking_summary: None,
1411 stop_reason: None,
1412 blocks: vec![serde_json::json!({
1413 "type": "tool_call",
1414 "id": "mock_call_1",
1415 "name": tool_name,
1416 "arguments": mock_args,
1417 "visibility": "internal",
1418 })],
1419 logprobs: Vec::new(),
1420 telemetry: ProviderTelemetry::default(),
1421 };
1422 if request.cache {
1423 apply_mock_prompt_cache(&mut result, &cache_key);
1424 }
1425 return Ok(result);
1426 }
1427 }
1428
1429 let tagged_done = system.is_some_and(|s| s.contains("<done>"));
1434
1435 let prose_body = if prompt_text.is_empty() {
1436 "Mock LLM response".to_string()
1437 } else {
1438 let word_count = prompt_text.split_whitespace().count();
1439 format!(
1440 "Mock response to {word_count}-word prompt: {}",
1441 prompt_text.chars().take(100).collect::<String>()
1442 )
1443 };
1444 let response = if tagged_done {
1445 format!("<assistant_prose>{prose_body}</assistant_prose>\n<done>##DONE##</done>")
1446 } else {
1447 prose_body
1448 };
1449
1450 let mut result = LlmResult {
1451 attempts: Default::default(),
1452 text_projection: None,
1453 served_fast: false,
1454 text: response.clone(),
1455 tool_calls: vec![],
1456 raw_tool_calls: Vec::new(),
1457 input_tokens: prompt_text.len() as i64,
1458 output_tokens: 30,
1459 cache_read_tokens: 0,
1460 cache_write_tokens: 0,
1461 cache_supported: true,
1462 model: request.model.clone(),
1463 provider: "mock".to_string(),
1464 thinking: None,
1465 thinking_summary: None,
1466 stop_reason: None,
1467 blocks: vec![serde_json::json!({
1468 "type": "output_text",
1469 "text": response,
1470 "visibility": "public",
1471 })],
1472 logprobs: Vec::new(),
1473 telemetry: ProviderTelemetry::default(),
1474 };
1475 if request.cache {
1476 apply_mock_prompt_cache(&mut result, &cache_key);
1477 }
1478 Ok(result)
1479}
1480
1481pub fn drain_tool_recordings() -> Vec<ToolCallRecord> {
1483 TOOL_RECORDINGS.with(|v| std::mem::take(&mut *v.borrow_mut()))
1484}
1485
1486#[cfg(test)]
1487#[path = "mock_tests.rs"]
1488mod tests;