1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{LazyLock, Mutex, MutexGuard};
6
7use super::api::{LlmResult, ProviderTelemetry};
8use crate::orchestration::ToolCallRecord;
9use crate::value::{ErrorCategory, VmError, VmValue};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum LlmReplayMode {
14 Off,
15 Record,
16 Replay,
17}
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20enum CliLlmMockMode {
21 #[default]
22 Off,
23 Replay,
24 Record,
25}
26
27#[derive(Default)]
28struct CliLlmMockState {
29 mode: CliLlmMockMode,
30 mocks: Vec<LlmMock>,
31 recordings: Vec<LlmMock>,
32}
33
34static CLI_LLM_MOCK_NEXT_SCOPE: AtomicU64 = AtomicU64::new(1);
35static CLI_LLM_MOCK_SCOPES: LazyLock<Mutex<BTreeMap<u64, CliLlmMockState>>> =
36 LazyLock::new(|| Mutex::new(BTreeMap::new()));
37
38#[derive(Clone)]
43pub struct MockError {
44 pub category: ErrorCategory,
45 pub message: String,
46 pub status: Option<u16>,
47 pub kind: Option<String>,
48 pub reason: Option<String>,
49 pub retry_after_ms: Option<u64>,
54}
55
56impl MockError {
57 fn has_provider_envelope(&self) -> bool {
58 self.status.is_some() || self.kind.is_some() || self.reason.is_some()
59 }
60}
61
62pub(crate) fn build_mock_error(
63 category: Option<String>,
64 message: Option<String>,
65 status: Option<u16>,
66 kind: Option<String>,
67 reason: Option<String>,
68 retry_after_ms: Option<u64>,
69) -> Result<MockError, String> {
70 if retry_after_ms.is_some_and(|ms| ms > i64::MAX as u64) {
71 return Err("error.retry_after_ms must fit in a signed 64-bit integer".to_string());
72 }
73 let kind = match kind {
74 Some(value) if value.trim().is_empty() => None,
75 Some(value) => {
76 let normalized = value.trim().to_ascii_lowercase();
77 if super::api::LlmErrorKind::parse(&normalized).is_none() {
78 return Err(format!("unknown error kind `{value}`"));
79 }
80 Some(normalized)
81 }
82 None => None,
83 };
84 let reason = reason.and_then(|value| {
85 let trimmed = value.trim();
86 if trimmed.is_empty() {
87 None
88 } else {
89 Some(trimmed.to_string())
90 }
91 });
92 let category_was_provided = category.is_some();
93 let category = match category {
94 Some(value) if value.trim().is_empty() => {
95 return Err("error.category must not be empty".to_string());
96 }
97 Some(value) => {
98 let normalized = value.trim().to_ascii_lowercase();
99 let category = ErrorCategory::parse(&normalized);
100 if category.as_str() != normalized {
101 return Err(format!("unknown error category `{value}`"));
102 }
103 category
104 }
105 None => infer_mock_error_category(status, kind.as_deref(), reason.as_deref()),
106 };
107 if !category_was_provided && kind.is_none() && status.is_none() && reason.is_none() {
108 return Err(
109 "error.category is required unless error.status, error.kind, or error.reason is set"
110 .to_string(),
111 );
112 }
113 Ok(MockError {
114 category,
115 message: message.unwrap_or_else(|| {
116 default_mock_error_message(status, kind.as_deref(), reason.as_deref())
117 }),
118 status,
119 kind,
120 reason,
121 retry_after_ms,
122 })
123}
124
125pub(crate) fn validate_mock_error_status(status: i64) -> Result<u16, String> {
126 let status = u16::try_from(status)
127 .map_err(|_| "error.status must be an HTTP status code".to_string())?;
128 reqwest::StatusCode::from_u16(status)
129 .map_err(|_| "error.status must be an HTTP status code".to_string())?;
130 Ok(status)
131}
132
133fn infer_mock_error_category(
134 status: Option<u16>,
135 kind: Option<&str>,
136 reason: Option<&str>,
137) -> ErrorCategory {
138 if let Some(status) = status {
139 match status {
140 401 | 403 => return ErrorCategory::Auth,
141 404 | 410 => return ErrorCategory::NotFound,
142 408 | 504 | 522 | 524 => return ErrorCategory::Timeout,
143 429 => return ErrorCategory::RateLimit,
144 503 | 529 => return ErrorCategory::Overloaded,
145 500 | 502 => return ErrorCategory::ServerError,
146 _ => {}
147 }
148 }
149 if let Some(reason) = reason {
150 match reason {
151 "rate_limit" => return ErrorCategory::RateLimit,
152 "timeout" => return ErrorCategory::Timeout,
153 "network_error" | "transient_network" => return ErrorCategory::TransientNetwork,
154 "server_error" | "provider_error" | "provider_5xx" | "upstream_unavailable" => {
155 return ErrorCategory::ServerError;
156 }
157 "auth_failure" => return ErrorCategory::Auth,
158 "model_unavailable" => return ErrorCategory::NotFound,
159 _ => {}
160 }
161 }
162 if kind == Some("transient") {
163 return ErrorCategory::ServerError;
164 }
165 ErrorCategory::Generic
166}
167
168fn default_mock_error_message(
169 status: Option<u16>,
170 kind: Option<&str>,
171 reason: Option<&str>,
172) -> String {
173 match (status, kind, reason) {
174 (Some(status), Some(kind), Some(reason)) => {
175 format!("HTTP {status} mock LLM error ({kind}/{reason})")
176 }
177 (Some(status), _, Some(reason)) => format!("HTTP {status} mock LLM error ({reason})"),
178 (Some(status), _, _) => format!("HTTP {status} mock LLM error"),
179 (None, Some(kind), Some(reason)) => format!("mock LLM error ({kind}/{reason})"),
180 (None, Some(kind), None) => format!("mock LLM error ({kind})"),
181 (None, None, Some(reason)) => format!("mock LLM error ({reason})"),
182 (None, None, None) => String::new(),
183 }
184}
185
186#[derive(Clone)]
187pub struct LlmMock {
188 pub text: String,
189 pub tool_calls: Vec<serde_json::Value>,
190 pub match_pattern: Option<String>, pub consume_on_match: bool,
192 pub input_tokens: Option<i64>,
193 pub output_tokens: Option<i64>,
194 pub cache_read_tokens: Option<i64>,
195 pub cache_write_tokens: Option<i64>,
196 pub thinking: Option<String>,
197 pub thinking_summary: Option<String>,
198 pub stop_reason: Option<String>,
199 pub model: String,
200 pub provider: Option<String>,
201 pub blocks: Option<Vec<serde_json::Value>>,
202 pub logprobs: Vec<serde_json::Value>,
203 pub error: Option<MockError>,
206 pub stream_chunks: Vec<String>,
213}
214
215#[derive(Clone)]
216pub(crate) struct LlmMockCall {
217 pub api_mode: String,
218 pub messages: Vec<serde_json::Value>,
219 pub system: Option<String>,
220 pub tools: Option<Vec<serde_json::Value>>,
221 pub provider_tools: Option<Vec<serde_json::Value>>,
222 pub tool_choice: Option<serde_json::Value>,
223 pub output_format: serde_json::Value,
224 pub thinking: serde_json::Value,
225 pub previous_response_id: Option<String>,
226 pub store: Option<bool>,
227 pub background: Option<bool>,
228 pub truncation: Option<String>,
229 pub compact: Option<bool>,
230 pub include: Option<Vec<String>>,
231 pub max_tool_calls: Option<i64>,
232}
233
234type LlmMockScope = (Vec<LlmMock>, Vec<LlmMockCall>, BTreeSet<String>);
235
236thread_local! {
237 static LLM_REPLAY_MODE: RefCell<LlmReplayMode> = const { RefCell::new(LlmReplayMode::Off) };
238 static LLM_FIXTURE_DIR: RefCell<String> = const { RefCell::new(String::new()) };
239 static TOOL_RECORDINGS: RefCell<Vec<ToolCallRecord>> = const { RefCell::new(Vec::new()) };
240 static LLM_MOCKS: RefCell<Vec<LlmMock>> = const { RefCell::new(Vec::new()) };
241 static CLI_LLM_MOCK_SCOPE: RefCell<Option<u64>> = const { RefCell::new(None) };
242 static LLM_MOCK_CALLS: RefCell<Vec<LlmMockCall>> = const { RefCell::new(Vec::new()) };
243 static LLM_PROMPT_CACHE: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
244 static LLM_MOCK_SCOPES: RefCell<Vec<LlmMockScope>> = const { RefCell::new(Vec::new()) };
245 static LLM_MOCK_STREAM_CHUNKS: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
250}
251
252pub(crate) fn set_mock_stream_chunks(chunks: Option<Vec<String>>) {
256 LLM_MOCK_STREAM_CHUNKS.with(|slot| *slot.borrow_mut() = chunks);
257}
258
259pub(crate) fn take_mock_stream_chunks() -> Option<Vec<String>> {
263 LLM_MOCK_STREAM_CHUNKS.with(|slot| slot.borrow_mut().take())
264}
265
266fn cli_llm_mock_scopes() -> MutexGuard<'static, BTreeMap<u64, CliLlmMockState>> {
267 CLI_LLM_MOCK_SCOPES
268 .lock()
269 .unwrap_or_else(|poisoned| poisoned.into_inner())
270}
271
272fn next_cli_llm_mock_scope_id() -> u64 {
273 CLI_LLM_MOCK_NEXT_SCOPE.fetch_add(1, Ordering::Relaxed)
274}
275
276pub(crate) fn current_cli_llm_mock_scope() -> Option<u64> {
277 CLI_LLM_MOCK_SCOPE.with(|scope| *scope.borrow())
278}
279
280fn install_cli_llm_mock_scope(state: CliLlmMockState) {
281 clear_cli_llm_mock_mode();
282 let scope = next_cli_llm_mock_scope_id();
283 cli_llm_mock_scopes().insert(scope, state);
284 CLI_LLM_MOCK_SCOPE.with(|slot| *slot.borrow_mut() = Some(scope));
285}
286
287pub(crate) fn push_llm_mock(mock: LlmMock) {
288 LLM_MOCKS.with(|v| v.borrow_mut().push(mock));
289}
290
291pub(crate) fn get_llm_mock_calls() -> Vec<LlmMockCall> {
292 LLM_MOCK_CALLS.with(|v| v.borrow().clone())
293}
294
295pub(crate) fn builtin_llm_mock_active() -> bool {
296 LLM_MOCKS.with(|v| !v.borrow().is_empty())
297}
298
299pub(crate) fn reset_llm_mock_state() {
300 LLM_MOCKS.with(|v| v.borrow_mut().clear());
301 clear_cli_llm_mock_mode();
302 LLM_MOCK_CALLS.with(|v| v.borrow_mut().clear());
303 LLM_PROMPT_CACHE.with(|v| v.borrow_mut().clear());
304 LLM_MOCK_SCOPES.with(|v| v.borrow_mut().clear());
305}
306
307pub(crate) fn push_llm_mock_scope() {
312 let mocks = LLM_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
313 let calls = LLM_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
314 let cache = LLM_PROMPT_CACHE.with(|v| std::mem::take(&mut *v.borrow_mut()));
315 LLM_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls, cache)));
316}
317
318pub(crate) fn pop_llm_mock_scope() -> bool {
324 let entry = LLM_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
325 match entry {
326 Some((mocks, calls, cache)) => {
327 LLM_MOCKS.with(|v| *v.borrow_mut() = mocks);
328 LLM_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
329 LLM_PROMPT_CACHE.with(|v| *v.borrow_mut() = cache);
330 true
331 }
332 None => false,
333 }
334}
335
336pub fn clear_cli_llm_mock_mode() {
337 let scope = CLI_LLM_MOCK_SCOPE.with(|slot| slot.borrow_mut().take());
338 if let Some(scope) = scope {
339 cli_llm_mock_scopes().remove(&scope);
340 }
341}
342
343pub fn install_cli_llm_mocks(mocks: Vec<LlmMock>) {
344 install_cli_llm_mock_scope(CliLlmMockState {
345 mode: CliLlmMockMode::Replay,
346 mocks,
347 recordings: Vec::new(),
348 });
349}
350
351pub fn enable_cli_llm_mock_recording() {
352 install_cli_llm_mock_scope(CliLlmMockState {
353 mode: CliLlmMockMode::Record,
354 mocks: Vec::new(),
355 recordings: Vec::new(),
356 });
357}
358
359pub fn take_cli_llm_recordings() -> Vec<LlmMock> {
360 let Some(scope) = current_cli_llm_mock_scope() else {
361 return Vec::new();
362 };
363 cli_llm_mock_scopes()
364 .get_mut(&scope)
365 .map(|state| std::mem::take(&mut state.recordings))
366 .unwrap_or_default()
367}
368
369pub(crate) fn cli_llm_mock_replay_active() -> bool {
370 cli_llm_mock_replay_active_for_scope(current_cli_llm_mock_scope())
371}
372
373pub(crate) fn cli_llm_mock_replay_active_for_scope(scope: Option<u64>) -> bool {
374 let Some(scope) = scope else {
375 return false;
376 };
377 cli_llm_mock_scopes()
378 .get(&scope)
379 .is_some_and(|state| state.mode == CliLlmMockMode::Replay)
380}
381
382fn record_llm_mock_call(request: &super::api::LlmRequestPayload) {
383 LLM_MOCK_CALLS.with(|v| {
384 v.borrow_mut().push(LlmMockCall {
385 api_mode: request.api_mode.as_str().to_string(),
386 messages: request.messages.clone(),
387 system: request.system.clone(),
388 tools: request.native_tools.clone(),
389 provider_tools: if request.provider_tools.is_empty() {
390 None
391 } else {
392 Some(request.provider_tools.clone())
393 },
394 tool_choice: request.tool_choice.clone(),
395 output_format: serde_json::to_value(&request.output_format).unwrap_or_else(|_| {
396 serde_json::json!({
397 "kind": "text"
398 })
399 }),
400 thinking: serde_json::to_value(&request.thinking).unwrap_or_else(|_| {
401 serde_json::json!({
402 "mode": "disabled"
403 })
404 }),
405 previous_response_id: request.previous_response_id.clone(),
406 store: request.store,
407 background: request.background,
408 truncation: request.truncation.clone(),
409 compact: request.compact,
410 include: request.include.clone(),
411 max_tool_calls: request.max_tool_calls,
412 });
413 });
414}
415
416fn build_mock_result(mock: &LlmMock, last_msg_len: usize) -> LlmResult {
418 let effective_text = if !mock.stream_chunks.is_empty() && mock.text.is_empty() {
423 mock.stream_chunks.concat()
424 } else {
425 mock.text.clone()
426 };
427 set_mock_stream_chunks(if mock.stream_chunks.is_empty() {
428 None
429 } else {
430 Some(mock.stream_chunks.clone())
431 });
432 let mock = &LlmMock {
433 text: effective_text,
434 ..mock.clone()
435 };
436 let (tool_calls, blocks) = if let Some(blocks) = &mock.blocks {
437 (mock.tool_calls.clone(), blocks.clone())
438 } else {
439 let mut blocks = Vec::new();
440
441 if !mock.text.is_empty() {
442 blocks.push(serde_json::json!({
443 "type": "output_text",
444 "text": mock.text,
445 "visibility": "public",
446 }));
447 }
448
449 let mut tool_calls = Vec::new();
450 for (i, tc) in mock.tool_calls.iter().enumerate() {
451 let id = format!("mock_call_{}", i + 1);
452 let name = tc.get("name").and_then(|n| n.as_str()).unwrap_or("unknown");
453 let arguments = tc
454 .get("arguments")
455 .cloned()
456 .unwrap_or(serde_json::json!({}));
457 tool_calls.push(serde_json::json!({
458 "id": id,
459 "type": "tool_call",
460 "name": name,
461 "arguments": arguments,
462 }));
463 blocks.push(serde_json::json!({
464 "type": "tool_call",
465 "id": id,
466 "name": name,
467 "arguments": arguments,
468 "visibility": "internal",
469 }));
470 }
471
472 (tool_calls, blocks)
473 };
474
475 LlmResult {
476 served_fast: false,
477 text: mock.text.clone(),
478 tool_calls,
479 input_tokens: mock.input_tokens.unwrap_or(last_msg_len as i64),
480 output_tokens: mock.output_tokens.unwrap_or(30),
481 cache_read_tokens: mock.cache_read_tokens.unwrap_or(0),
482 cache_write_tokens: mock.cache_write_tokens.unwrap_or(0),
483 cache_supported: true,
484 model: mock.model.clone(),
485 provider: mock.provider.clone().unwrap_or_else(|| "mock".to_string()),
486 thinking: mock.thinking.clone(),
487 thinking_summary: mock.thinking_summary.clone(),
488 stop_reason: mock.stop_reason.clone(),
489 blocks,
490 logprobs: mock.logprobs.clone(),
491 telemetry: ProviderTelemetry::default(),
492 }
493}
494
495use harn_glob::match_prose as mock_glob_match;
499
500fn collect_mock_match_strings(value: &serde_json::Value, out: &mut Vec<String>) {
501 match value {
502 serde_json::Value::String(text) if !text.is_empty() => out.push(text.clone()),
503 serde_json::Value::String(_) => {}
504 serde_json::Value::Array(items) => {
505 for item in items {
506 collect_mock_match_strings(item, out);
507 }
508 }
509 serde_json::Value::Object(map) => {
510 for value in map.values() {
511 collect_mock_match_strings(value, out);
512 }
513 }
514 _ => {}
515 }
516}
517
518fn mock_match_text(messages: &[serde_json::Value]) -> String {
519 let mut parts = Vec::new();
520 for message in messages {
521 collect_mock_match_strings(message, &mut parts);
522 }
523 parts.join("\n")
524}
525
526fn mock_last_prompt_text(messages: &[serde_json::Value]) -> String {
527 for message in messages.iter().rev() {
528 let Some(content) = message.get("content") else {
529 continue;
530 };
531 let mut parts = Vec::new();
532 collect_mock_match_strings(content, &mut parts);
533 let text = parts.join("\n");
534 if !text.trim().is_empty() {
535 return text;
536 }
537 }
538 String::new()
539}
540
541fn mock_prompt_cache_key(
542 model: &str,
543 messages: &[serde_json::Value],
544 system: Option<&str>,
545) -> String {
546 serde_json::to_string(&serde_json::json!({
547 "model": model,
548 "system": system,
549 "messages": messages,
550 }))
551 .unwrap_or_default()
552}
553
554fn apply_mock_prompt_cache(result: &mut LlmResult, cache_key: &str) {
555 if result.cache_read_tokens > 0 || result.cache_write_tokens > 0 {
556 return;
557 }
558 let cache_tokens = result.input_tokens.max(0);
559 if cache_tokens == 0 {
560 return;
561 }
562 let cache_hit = LLM_PROMPT_CACHE.with(|cache| {
563 let mut cache = cache.borrow_mut();
564 if cache.contains(cache_key) {
565 true
566 } else {
567 cache.insert(cache_key.to_string());
568 false
569 }
570 });
571 if cache_hit {
572 result.cache_read_tokens = cache_tokens;
573 } else {
574 result.cache_write_tokens = cache_tokens;
575 }
576}
577
578fn mock_error_to_vm_error(err: &MockError) -> VmError {
582 let message = mock_error_message(err);
583 if err.has_provider_envelope() {
584 let classified = super::api::classify_llm_error(err.category.clone(), &message);
585 let mut dict = BTreeMap::new();
586 dict.put_str("category", err.category.as_str());
587 dict.put_str(
588 "kind",
589 err.kind
590 .as_deref()
591 .unwrap_or_else(|| classified.kind.as_str()),
592 );
593 dict.put_str(
594 "reason",
595 err.reason
596 .as_deref()
597 .unwrap_or_else(|| classified.reason.as_str()),
598 );
599 dict.put_str("message", message);
600 if let Some(status) = err.status {
601 dict.insert("status".to_string(), VmValue::Int(i64::from(status)));
602 }
603 if let Some(retry_after_ms) = err.retry_after_ms {
604 dict.insert(
605 "retry_after_ms".to_string(),
606 VmValue::Int(retry_after_ms as i64),
607 );
608 }
609 return VmError::Thrown(VmValue::dict(dict));
610 }
611
612 VmError::CategorizedError {
613 message,
614 category: err.category.clone(),
615 }
616}
617
618fn mock_error_message(err: &MockError) -> String {
619 let Some(ms) = err.retry_after_ms else {
623 return err.message.clone();
624 };
625 if err.has_provider_envelope() {
626 return err.message.clone();
627 }
628 let secs = (ms as f64 / 1000.0).max(0.0);
629 let sep = if err.message.is_empty() || err.message.ends_with('\n') {
630 ""
631 } else {
632 "\n"
633 };
634 format!("{}{sep}retry-after: {secs}\n", err.message)
635}
636
637fn try_match_mock_queue(
641 mocks: &mut Vec<LlmMock>,
642 match_text: &str,
643) -> Option<Result<LlmResult, VmError>> {
644 if let Some(idx) = mocks.iter().position(|m| m.match_pattern.is_none()) {
645 let mock = mocks.remove(idx);
646 return Some(match &mock.error {
647 Some(err) => Err(mock_error_to_vm_error(err)),
648 None => Ok(build_mock_result(&mock, match_text.len())),
649 });
650 }
651
652 for idx in 0..mocks.len() {
653 let mock = &mocks[idx];
654 if let Some(ref pattern) = mock.match_pattern {
655 if mock_glob_match(pattern, match_text) {
656 if mock.consume_on_match {
657 let mock = mocks.remove(idx);
658 return Some(match &mock.error {
659 Some(err) => Err(mock_error_to_vm_error(err)),
660 None => Ok(build_mock_result(&mock, match_text.len())),
661 });
662 }
663 return Some(match &mock.error {
664 Some(err) => Err(mock_error_to_vm_error(err)),
665 None => Ok(build_mock_result(mock, match_text.len())),
666 });
667 }
668 }
669 }
670
671 None
672}
673
674fn try_match_builtin_mock(match_text: &str) -> Option<Result<LlmResult, VmError>> {
675 LLM_MOCKS.with(|mocks| try_match_mock_queue(&mut mocks.borrow_mut(), match_text))
676}
677
678fn try_match_cli_mock(scope: Option<u64>, match_text: &str) -> Option<Result<LlmResult, VmError>> {
679 let scope = scope?;
680 let mut scopes = cli_llm_mock_scopes();
681 let state = scopes.get_mut(&scope)?;
682 if state.mode != CliLlmMockMode::Replay {
683 return None;
684 }
685 try_match_mock_queue(&mut state.mocks, match_text)
686}
687
688pub(crate) fn record_cli_llm_result(request: &super::api::LlmRequestPayload, result: &LlmResult) {
689 record_unified_tape_llm_call(result);
690 let Some(scope) = request.cli_llm_mock_scope else {
691 return;
692 };
693 let mut scopes = cli_llm_mock_scopes();
694 let Some(state) = scopes.get_mut(&scope) else {
695 return;
696 };
697 if state.mode != CliLlmMockMode::Record {
698 return;
699 }
700 state.recordings.push(LlmMock {
701 text: result.text.clone(),
702 tool_calls: result.tool_calls.clone(),
703 match_pattern: None,
704 consume_on_match: false,
705 input_tokens: Some(result.input_tokens),
706 output_tokens: Some(result.output_tokens),
707 cache_read_tokens: Some(result.cache_read_tokens),
708 cache_write_tokens: Some(result.cache_write_tokens),
709 thinking: result.thinking.clone(),
710 thinking_summary: result.thinking_summary.clone(),
711 stop_reason: result.stop_reason.clone(),
712 model: result.model.clone(),
713 provider: Some(result.provider.clone()),
714 blocks: Some(result.blocks.clone()),
715 logprobs: result.logprobs.clone(),
716 error: None,
717 stream_chunks: Vec::new(),
718 });
719}
720
721fn record_unified_tape_llm_call(result: &LlmResult) {
728 if crate::testbench::tape::active_recorder().is_none() {
729 return;
730 }
731 let response_json = serde_json::to_vec(result).unwrap_or_else(|_| Vec::new());
732 let request_digest = LLM_MOCK_CALLS
733 .with(|calls| calls.borrow().last().cloned())
734 .map(|call| {
735 let mut request = serde_json::Map::new();
736 request.insert("messages".to_string(), serde_json::json!(call.messages));
737 request.insert("system".to_string(), serde_json::json!(call.system));
738 request.insert("tools".to_string(), serde_json::json!(call.tools));
739 request.insert(
740 "tool_choice".to_string(),
741 serde_json::json!(call.tool_choice),
742 );
743 request.insert("thinking".to_string(), serde_json::json!(call.thinking));
744 request.insert("model".to_string(), serde_json::json!(result.model));
745 if call.api_mode != "chat_completions" {
746 request.insert("api_mode".to_string(), serde_json::json!(call.api_mode));
747 }
748 if call.provider_tools.is_some() {
749 request.insert(
750 "provider_tools".to_string(),
751 serde_json::json!(call.provider_tools),
752 );
753 }
754 if call
755 .output_format
756 .get("kind")
757 .and_then(|value| value.as_str())
758 != Some("text")
759 {
760 request.insert(
761 "output_format".to_string(),
762 serde_json::json!(call.output_format),
763 );
764 }
765 if call.previous_response_id.is_some() {
766 request.insert(
767 "previous_response_id".to_string(),
768 serde_json::json!(call.previous_response_id),
769 );
770 }
771 if call.store.is_some() {
772 request.insert("store".to_string(), serde_json::json!(call.store));
773 }
774 if call.background.is_some() {
775 request.insert("background".to_string(), serde_json::json!(call.background));
776 }
777 if call.truncation.is_some() {
778 request.insert("truncation".to_string(), serde_json::json!(call.truncation));
779 }
780 if call.compact.is_some() {
781 request.insert("compact".to_string(), serde_json::json!(call.compact));
782 }
783 if call.include.is_some() {
784 request.insert("include".to_string(), serde_json::json!(call.include));
785 }
786 if call.max_tool_calls.is_some() {
787 request.insert(
788 "max_tool_calls".to_string(),
789 serde_json::json!(call.max_tool_calls),
790 );
791 }
792 let serialized =
793 serde_json::to_vec(&serde_json::Value::Object(request)).unwrap_or_default();
794 crate::testbench::tape::content_hash(&serialized)
795 })
796 .unwrap_or_else(|| {
797 crate::testbench::tape::content_hash(result.text.as_bytes())
800 });
801 crate::testbench::tape::with_active_recorder(|recorder| {
802 let response = recorder.payload_from_bytes(response_json);
803 Some(crate::testbench::tape::TapeRecordKind::LlmCall {
804 request_digest,
805 response,
806 })
807 });
808}
809
810fn unmatched_cli_prompt_error(match_text: &str) -> VmError {
811 let mut snippet: String = match_text.chars().take(200).collect();
812 if match_text.chars().count() > 200 {
813 snippet.push_str("...");
814 }
815 VmError::Runtime(format!("No --llm-mock fixture matched prompt: {snippet:?}"))
816}
817
818pub fn set_replay_mode(mode: LlmReplayMode, fixture_dir: &str) {
820 LLM_REPLAY_MODE.with(|v| *v.borrow_mut() = mode);
821 LLM_FIXTURE_DIR.with(|v| *v.borrow_mut() = fixture_dir.to_string());
822}
823
824pub(crate) fn get_replay_mode() -> LlmReplayMode {
825 LLM_REPLAY_MODE.with(|v| *v.borrow())
826}
827
828pub(crate) fn get_fixture_dir() -> String {
829 LLM_FIXTURE_DIR.with(|v| v.borrow().clone())
830}
831
832pub(crate) fn fixture_hash(
834 model: &str,
835 messages: &[serde_json::Value],
836 system: Option<&str>,
837) -> String {
838 use std::hash::{Hash, Hasher};
839 let mut hasher = std::collections::hash_map::DefaultHasher::new();
840 model.hash(&mut hasher);
841 serde_json::to_string(messages)
843 .unwrap_or_default()
844 .hash(&mut hasher);
845 system.hash(&mut hasher);
846 format!("{:016x}", hasher.finish())
847}
848
849pub(crate) fn save_fixture(hash: &str, result: &LlmResult) {
850 let dir = get_fixture_dir();
851 if dir.is_empty() {
852 return;
853 }
854 let _ = std::fs::create_dir_all(&dir);
855 let path = format!("{dir}/{hash}.json");
856 let json = serde_json::json!({
857 "text": result.text,
858 "tool_calls": result.tool_calls,
859 "input_tokens": result.input_tokens,
860 "output_tokens": result.output_tokens,
861 "cache_read_tokens": result.cache_read_tokens,
862 "cache_write_tokens": result.cache_write_tokens,
863 "cache_creation_input_tokens": result.cache_write_tokens,
864 "model": result.model,
865 "provider": result.provider,
866 "thinking": result.thinking,
867 "thinking_summary": result.thinking_summary,
868 "stop_reason": result.stop_reason,
869 "blocks": result.blocks,
870 "logprobs": result.logprobs,
871 });
872 let _ = std::fs::write(
873 &path,
874 serde_json::to_string_pretty(&json).unwrap_or_default(),
875 );
876}
877
878pub(crate) fn load_fixture(hash: &str) -> Option<LlmResult> {
879 let dir = get_fixture_dir();
880 if dir.is_empty() {
881 return None;
882 }
883 let path = format!("{dir}/{hash}.json");
884 let content = std::fs::read_to_string(&path).ok()?;
885 let json: serde_json::Value = serde_json::from_str(&content).ok()?;
886 Some(LlmResult {
887 served_fast: false,
888 text: json["text"].as_str().unwrap_or("").to_string(),
889 tool_calls: json["tool_calls"].as_array().cloned().unwrap_or_default(),
890 input_tokens: json["input_tokens"].as_i64().unwrap_or(0),
891 output_tokens: json["output_tokens"].as_i64().unwrap_or(0),
892 cache_read_tokens: json["cache_read_tokens"].as_i64().unwrap_or(0),
893 cache_write_tokens: json["cache_write_tokens"]
894 .as_i64()
895 .or_else(|| json["cache_creation_input_tokens"].as_i64())
896 .unwrap_or(0),
897 cache_supported: json["cache_supported"].as_bool().unwrap_or(true),
898 model: json["model"].as_str().unwrap_or("").to_string(),
899 provider: json["provider"].as_str().unwrap_or("mock").to_string(),
900 thinking: json["thinking"].as_str().map(|s| s.to_string()),
901 thinking_summary: json["thinking_summary"].as_str().map(|s| s.to_string()),
902 stop_reason: json["stop_reason"].as_str().map(|s| s.to_string()),
903 blocks: json["blocks"].as_array().cloned().unwrap_or_default(),
904 logprobs: json["logprobs"].as_array().cloned().unwrap_or_default(),
905 telemetry: serde_json::from_value(json["telemetry"].clone()).unwrap_or_default(),
906 })
907}
908
909fn mock_required_args(tool_schema: &serde_json::Value) -> serde_json::Value {
913 let mut args = serde_json::Map::new();
914 let input_schema = tool_schema
918 .get("input_schema")
919 .or_else(|| tool_schema.get("inputSchema"))
920 .or_else(|| {
921 tool_schema
922 .get("function")
923 .and_then(|f| f.get("parameters"))
924 })
925 .or_else(|| tool_schema.get("parameters"));
926 let Some(schema) = input_schema else {
927 return serde_json::Value::Object(args);
928 };
929 let required: std::collections::BTreeSet<String> = schema
930 .get("required")
931 .and_then(|r| r.as_array())
932 .map(|arr| {
933 arr.iter()
934 .filter_map(|v| v.as_str().map(|s| s.to_string()))
935 .collect()
936 })
937 .unwrap_or_default();
938 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
939 for (name, prop) in props {
940 if !required.contains(name) {
941 continue;
942 }
943 let ty = prop
944 .get("type")
945 .and_then(|t| t.as_str())
946 .unwrap_or("string");
947 let placeholder = match ty {
948 "integer" => serde_json::json!(0),
949 "number" => serde_json::json!(0.0),
950 "boolean" => serde_json::json!(false),
951 "array" => serde_json::json!([]),
952 "object" => serde_json::json!({}),
953 _ => serde_json::json!(""),
954 };
955 args.insert(name.clone(), placeholder);
956 }
957 }
958 serde_json::Value::Object(args)
959}
960
961fn mock_tool_name(tool: &serde_json::Value) -> Option<&str> {
962 tool.get("name")
963 .or_else(|| {
964 tool.get("function")
965 .and_then(|function| function.get("name"))
966 })
967 .and_then(|name| name.as_str())
968}
969
970fn mock_auto_tool_candidate(tools: &[serde_json::Value]) -> Option<&serde_json::Value> {
971 tools
972 .iter()
973 .find(|tool| mock_tool_name(tool) != Some("agent_await_resumption"))
974}
975
976pub(crate) fn mock_llm_response(
981 request: &super::api::LlmRequestPayload,
982) -> Result<LlmResult, VmError> {
983 record_llm_mock_call(request);
984 set_mock_stream_chunks(None);
987
988 let messages = &request.messages;
989 let system = request.system.as_deref();
990 let match_text = mock_match_text(messages);
991 let prompt_text = mock_last_prompt_text(messages);
992 let cache_key = mock_prompt_cache_key(&request.model, messages, system);
993
994 if let Some(matched) = try_match_cli_mock(request.cli_llm_mock_scope, &match_text) {
995 return matched.map(|mut result| {
996 if request.cache {
997 apply_mock_prompt_cache(&mut result, &cache_key);
998 }
999 result
1000 });
1001 }
1002
1003 if let Some(matched) = try_match_builtin_mock(&match_text) {
1004 return matched.map(|mut result| {
1005 if request.cache {
1006 apply_mock_prompt_cache(&mut result, &cache_key);
1007 }
1008 result
1009 });
1010 }
1011
1012 if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) {
1013 return Err(unmatched_cli_prompt_error(&match_text));
1014 }
1015
1016 if let Some(tools) = request.native_tools.as_deref() {
1019 if let Some(first_tool) = mock_auto_tool_candidate(tools) {
1020 let tool_name = mock_tool_name(first_tool).unwrap_or("unknown");
1021 let mock_args = mock_required_args(first_tool);
1022 let mut result = LlmResult {
1023 served_fast: false,
1024 text: String::new(),
1025 tool_calls: vec![serde_json::json!({
1026 "id": "mock_call_1",
1027 "type": "tool_call",
1028 "name": tool_name,
1029 "arguments": mock_args
1030 })],
1031 input_tokens: prompt_text.len() as i64,
1032 output_tokens: 20,
1033 cache_read_tokens: 0,
1034 cache_write_tokens: 0,
1035 cache_supported: true,
1036 model: request.model.clone(),
1037 provider: "mock".to_string(),
1038 thinking: None,
1039 thinking_summary: None,
1040 stop_reason: None,
1041 blocks: vec![serde_json::json!({
1042 "type": "tool_call",
1043 "id": "mock_call_1",
1044 "name": tool_name,
1045 "arguments": mock_args,
1046 "visibility": "internal",
1047 })],
1048 logprobs: Vec::new(),
1049 telemetry: ProviderTelemetry::default(),
1050 };
1051 if request.cache {
1052 apply_mock_prompt_cache(&mut result, &cache_key);
1053 }
1054 return Ok(result);
1055 }
1056 }
1057
1058 let tagged_done = system.is_some_and(|s| s.contains("<done>"));
1063
1064 let prose_body = if prompt_text.is_empty() {
1065 "Mock LLM response".to_string()
1066 } else {
1067 let word_count = prompt_text.split_whitespace().count();
1068 format!(
1069 "Mock response to {word_count}-word prompt: {}",
1070 prompt_text.chars().take(100).collect::<String>()
1071 )
1072 };
1073 let response = if tagged_done {
1074 format!("<assistant_prose>{prose_body}</assistant_prose>\n<done>##DONE##</done>")
1075 } else {
1076 prose_body
1077 };
1078
1079 let mut result = LlmResult {
1080 served_fast: false,
1081 text: response.clone(),
1082 tool_calls: vec![],
1083 input_tokens: prompt_text.len() as i64,
1084 output_tokens: 30,
1085 cache_read_tokens: 0,
1086 cache_write_tokens: 0,
1087 cache_supported: true,
1088 model: request.model.clone(),
1089 provider: "mock".to_string(),
1090 thinking: None,
1091 thinking_summary: None,
1092 stop_reason: None,
1093 blocks: vec![serde_json::json!({
1094 "type": "output_text",
1095 "text": response,
1096 "visibility": "public",
1097 })],
1098 logprobs: Vec::new(),
1099 telemetry: ProviderTelemetry::default(),
1100 };
1101 if request.cache {
1102 apply_mock_prompt_cache(&mut result, &cache_key);
1103 }
1104 Ok(result)
1105}
1106
1107pub fn drain_tool_recordings() -> Vec<ToolCallRecord> {
1109 TOOL_RECORDINGS.with(|v| std::mem::take(&mut *v.borrow_mut()))
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use crate::llm::api::LlmRequestPayload;
1116
1117 fn text_mock(text: &str) -> LlmMock {
1118 LlmMock {
1119 text: text.to_string(),
1120 tool_calls: Vec::new(),
1121 match_pattern: None,
1122 consume_on_match: false,
1123 input_tokens: None,
1124 output_tokens: None,
1125 cache_read_tokens: None,
1126 cache_write_tokens: None,
1127 thinking: None,
1128 thinking_summary: None,
1129 stop_reason: None,
1130 model: "fixture-model".to_string(),
1131 provider: None,
1132 blocks: None,
1133 logprobs: Vec::new(),
1134 error: None,
1135 stream_chunks: Vec::new(),
1136 }
1137 }
1138
1139 #[test]
1140 fn cli_llm_mock_replay_scope_survives_provider_worker_thread() {
1141 reset_llm_mock_state();
1142 install_cli_llm_mocks(vec![text_mock("cross-thread replay")]);
1143 let request = LlmRequestPayload::from(&crate::llm::api::options::base_opts("anthropic"));
1144
1145 assert!(request.cli_llm_mock_scope.is_some());
1146 assert!(crate::llm::providers::MockProvider::should_intercept_request(&request));
1147
1148 let result = std::thread::spawn(move || {
1149 assert!(crate::llm::providers::MockProvider::should_intercept_request(&request));
1150 mock_llm_response(&request)
1151 })
1152 .join()
1153 .expect("provider worker thread")
1154 .expect("mock response");
1155
1156 assert_eq!(result.text, "cross-thread replay");
1157 clear_cli_llm_mock_mode();
1158 }
1159
1160 #[test]
1161 fn cli_llm_mock_record_scope_collects_provider_worker_thread_results() {
1162 reset_llm_mock_state();
1163 enable_cli_llm_mock_recording();
1164 let request = LlmRequestPayload::from(&crate::llm::api::options::base_opts("anthropic"));
1165 let result = build_mock_result(&text_mock("cross-thread record"), 7);
1166
1167 assert!(request.cli_llm_mock_scope.is_some());
1168 std::thread::spawn(move || record_cli_llm_result(&request, &result))
1169 .join()
1170 .expect("provider worker thread");
1171
1172 let recordings = take_cli_llm_recordings();
1173 assert_eq!(recordings.len(), 1);
1174 assert_eq!(recordings[0].text, "cross-thread record");
1175 clear_cli_llm_mock_mode();
1176 }
1177}