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, RawProviderToolCall};
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 raw_tool_calls: Vec<RawProviderToolCall>,
191 pub match_pattern: Option<String>, pub consume_on_match: bool,
193 pub input_tokens: Option<i64>,
194 pub output_tokens: Option<i64>,
195 pub cache_read_tokens: Option<i64>,
196 pub cache_write_tokens: Option<i64>,
197 pub thinking: Option<String>,
198 pub thinking_summary: Option<String>,
199 pub stop_reason: Option<String>,
200 pub model: String,
201 pub provider: Option<String>,
202 pub blocks: Option<Vec<serde_json::Value>>,
203 pub logprobs: Vec<serde_json::Value>,
204 pub error: Option<MockError>,
207 pub stream_chunks: Vec<String>,
214}
215
216#[derive(Clone)]
217pub(crate) struct LlmMockCall {
218 pub api_mode: String,
219 pub messages: Vec<serde_json::Value>,
220 pub system: Option<String>,
221 pub tools: Option<Vec<serde_json::Value>>,
222 pub provider_tools: Option<Vec<serde_json::Value>>,
223 pub tool_choice: Option<serde_json::Value>,
224 pub output_format: serde_json::Value,
225 pub thinking: serde_json::Value,
226 pub previous_response_id: Option<String>,
227 pub store: Option<bool>,
228 pub background: Option<bool>,
229 pub truncation: Option<String>,
230 pub compact: Option<bool>,
231 pub include: Option<Vec<String>>,
232 pub max_tool_calls: Option<i64>,
233}
234
235type LlmMockScope = (Vec<LlmMock>, Vec<LlmMockCall>, BTreeSet<String>);
236
237thread_local! {
238 static LLM_REPLAY_MODE: RefCell<LlmReplayMode> = const { RefCell::new(LlmReplayMode::Off) };
239 static LLM_FIXTURE_DIR: RefCell<String> = const { RefCell::new(String::new()) };
240 static TOOL_RECORDINGS: RefCell<Vec<ToolCallRecord>> = const { RefCell::new(Vec::new()) };
241 static LLM_MOCKS: RefCell<Vec<LlmMock>> = const { RefCell::new(Vec::new()) };
242 static CLI_LLM_MOCK_SCOPE: RefCell<Option<u64>> = const { RefCell::new(None) };
243 static LLM_MOCK_CALLS: RefCell<Vec<LlmMockCall>> = const { RefCell::new(Vec::new()) };
244 static LLM_PROMPT_CACHE: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
245 static LLM_MOCK_SCOPES: RefCell<Vec<LlmMockScope>> = const { RefCell::new(Vec::new()) };
246 static LLM_MOCK_STREAM_CHUNKS: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
251}
252
253pub(crate) fn set_mock_stream_chunks(chunks: Option<Vec<String>>) {
257 LLM_MOCK_STREAM_CHUNKS.with(|slot| *slot.borrow_mut() = chunks);
258}
259
260pub(crate) fn take_mock_stream_chunks() -> Option<Vec<String>> {
264 LLM_MOCK_STREAM_CHUNKS.with(|slot| slot.borrow_mut().take())
265}
266
267fn cli_llm_mock_scopes() -> MutexGuard<'static, BTreeMap<u64, CliLlmMockState>> {
268 CLI_LLM_MOCK_SCOPES
269 .lock()
270 .unwrap_or_else(|poisoned| poisoned.into_inner())
271}
272
273fn next_cli_llm_mock_scope_id() -> u64 {
274 CLI_LLM_MOCK_NEXT_SCOPE.fetch_add(1, Ordering::Relaxed)
275}
276
277pub(crate) fn current_cli_llm_mock_scope() -> Option<u64> {
278 CLI_LLM_MOCK_SCOPE.with(|scope| *scope.borrow())
279}
280
281fn install_cli_llm_mock_scope(state: CliLlmMockState) {
282 clear_cli_llm_mock_mode();
283 let scope = next_cli_llm_mock_scope_id();
284 cli_llm_mock_scopes().insert(scope, state);
285 CLI_LLM_MOCK_SCOPE.with(|slot| *slot.borrow_mut() = Some(scope));
286}
287
288pub(crate) fn push_llm_mock(mock: LlmMock) {
289 LLM_MOCKS.with(|v| v.borrow_mut().push(mock));
290}
291
292pub(crate) fn get_llm_mock_calls() -> Vec<LlmMockCall> {
293 LLM_MOCK_CALLS.with(|v| v.borrow().clone())
294}
295
296pub(crate) fn builtin_llm_mock_active() -> bool {
297 LLM_MOCKS.with(|v| !v.borrow().is_empty())
298}
299
300pub(crate) fn reset_llm_mock_state() {
301 LLM_MOCKS.with(|v| v.borrow_mut().clear());
302 clear_cli_llm_mock_mode();
303 LLM_MOCK_CALLS.with(|v| v.borrow_mut().clear());
304 LLM_PROMPT_CACHE.with(|v| v.borrow_mut().clear());
305 LLM_MOCK_SCOPES.with(|v| v.borrow_mut().clear());
306}
307
308pub(crate) fn push_llm_mock_scope() {
313 let mocks = LLM_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
314 let calls = LLM_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
315 let cache = LLM_PROMPT_CACHE.with(|v| std::mem::take(&mut *v.borrow_mut()));
316 LLM_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls, cache)));
317}
318
319pub(crate) fn pop_llm_mock_scope() -> bool {
325 let entry = LLM_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
326 match entry {
327 Some((mocks, calls, cache)) => {
328 LLM_MOCKS.with(|v| *v.borrow_mut() = mocks);
329 LLM_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
330 LLM_PROMPT_CACHE.with(|v| *v.borrow_mut() = cache);
331 true
332 }
333 None => false,
334 }
335}
336
337pub fn clear_cli_llm_mock_mode() {
338 let scope = CLI_LLM_MOCK_SCOPE.with(|slot| slot.borrow_mut().take());
339 if let Some(scope) = scope {
340 cli_llm_mock_scopes().remove(&scope);
341 }
342}
343
344pub fn install_cli_llm_mocks(mocks: Vec<LlmMock>) {
345 install_cli_llm_mock_scope(CliLlmMockState {
346 mode: CliLlmMockMode::Replay,
347 mocks,
348 recordings: Vec::new(),
349 });
350}
351
352pub fn enable_cli_llm_mock_recording() {
353 install_cli_llm_mock_scope(CliLlmMockState {
354 mode: CliLlmMockMode::Record,
355 mocks: Vec::new(),
356 recordings: Vec::new(),
357 });
358}
359
360pub fn take_cli_llm_recordings() -> Vec<LlmMock> {
361 let Some(scope) = current_cli_llm_mock_scope() else {
362 return Vec::new();
363 };
364 cli_llm_mock_scopes()
365 .get_mut(&scope)
366 .map(|state| std::mem::take(&mut state.recordings))
367 .unwrap_or_default()
368}
369
370pub(crate) fn cli_llm_mock_replay_active() -> bool {
371 cli_llm_mock_replay_active_for_scope(current_cli_llm_mock_scope())
372}
373
374pub(crate) fn cli_llm_mock_replay_active_for_scope(scope: Option<u64>) -> bool {
375 let Some(scope) = scope else {
376 return false;
377 };
378 cli_llm_mock_scopes()
379 .get(&scope)
380 .is_some_and(|state| state.mode == CliLlmMockMode::Replay)
381}
382
383fn record_llm_mock_call(request: &super::api::LlmRequestPayload) {
384 LLM_MOCK_CALLS.with(|v| {
385 v.borrow_mut().push(LlmMockCall {
386 api_mode: request.api_mode.as_str().to_string(),
387 messages: request.messages.clone(),
388 system: request.system.clone(),
389 tools: request.native_tools.clone(),
390 provider_tools: if request.provider_tools.is_empty() {
391 None
392 } else {
393 Some(request.provider_tools.clone())
394 },
395 tool_choice: request.tool_choice.clone(),
396 output_format: serde_json::to_value(&request.output_format).unwrap_or_else(|_| {
397 serde_json::json!({
398 "kind": "text"
399 })
400 }),
401 thinking: serde_json::to_value(&request.thinking).unwrap_or_else(|_| {
402 serde_json::json!({
403 "mode": "disabled"
404 })
405 }),
406 previous_response_id: request.previous_response_id.clone(),
407 store: request.store,
408 background: request.background,
409 truncation: request.truncation.clone(),
410 compact: request.compact,
411 include: request.include.clone(),
412 max_tool_calls: request.max_tool_calls,
413 });
414 });
415}
416
417fn build_mock_result(mock: &LlmMock, last_msg_len: usize) -> LlmResult {
419 let effective_text = if !mock.stream_chunks.is_empty() && mock.text.is_empty() {
424 mock.stream_chunks.concat()
425 } else {
426 mock.text.clone()
427 };
428 set_mock_stream_chunks(if mock.stream_chunks.is_empty() {
429 None
430 } else {
431 Some(mock.stream_chunks.clone())
432 });
433 let mock = &LlmMock {
434 text: effective_text,
435 ..mock.clone()
436 };
437 let (tool_calls, blocks) = if let Some(blocks) = &mock.blocks {
438 (mock.tool_calls.clone(), blocks.clone())
439 } else {
440 let mut blocks = Vec::new();
441
442 if !mock.text.is_empty() {
443 blocks.push(serde_json::json!({
444 "type": "output_text",
445 "text": mock.text,
446 "visibility": "public",
447 }));
448 }
449
450 let mut tool_calls = Vec::new();
451 for (i, tc) in mock.tool_calls.iter().enumerate() {
452 let id = format!("mock_call_{}", i + 1);
453 let name = tc.get("name").and_then(|n| n.as_str()).unwrap_or("unknown");
454 let arguments = tc
455 .get("arguments")
456 .cloned()
457 .unwrap_or(serde_json::json!({}));
458 tool_calls.push(serde_json::json!({
459 "id": id,
460 "type": "tool_call",
461 "name": name,
462 "arguments": arguments,
463 }));
464 blocks.push(serde_json::json!({
465 "type": "tool_call",
466 "id": id,
467 "name": name,
468 "arguments": arguments,
469 "visibility": "internal",
470 }));
471 }
472
473 (tool_calls, blocks)
474 };
475
476 LlmResult {
477 served_fast: false,
478 text: mock.text.clone(),
479 raw_tool_calls: if mock.raw_tool_calls.is_empty() {
480 Vec::new()
481 } else {
482 mock.raw_tool_calls.clone()
483 },
484 tool_calls,
485 input_tokens: mock.input_tokens.unwrap_or(last_msg_len as i64),
486 output_tokens: mock.output_tokens.unwrap_or(30),
487 cache_read_tokens: mock.cache_read_tokens.unwrap_or(0),
488 cache_write_tokens: mock.cache_write_tokens.unwrap_or(0),
489 cache_supported: true,
490 model: mock.model.clone(),
491 provider: mock.provider.clone().unwrap_or_else(|| "mock".to_string()),
492 thinking: mock.thinking.clone(),
493 thinking_summary: mock.thinking_summary.clone(),
494 stop_reason: mock.stop_reason.clone(),
495 blocks,
496 logprobs: mock.logprobs.clone(),
497 telemetry: ProviderTelemetry::default(),
498 }
499}
500
501use harn_glob::match_prose as mock_glob_match;
505
506fn collect_mock_match_strings(value: &serde_json::Value, out: &mut Vec<String>) {
507 match value {
508 serde_json::Value::String(text) if !text.is_empty() => out.push(text.clone()),
509 serde_json::Value::String(_) => {}
510 serde_json::Value::Array(items) => {
511 for item in items {
512 collect_mock_match_strings(item, out);
513 }
514 }
515 serde_json::Value::Object(map) => {
516 for value in map.values() {
517 collect_mock_match_strings(value, out);
518 }
519 }
520 _ => {}
521 }
522}
523
524fn mock_match_text(messages: &[serde_json::Value]) -> String {
525 let mut parts = Vec::new();
526 for message in messages {
527 collect_mock_match_strings(message, &mut parts);
528 }
529 parts.join("\n")
530}
531
532fn mock_last_prompt_text(messages: &[serde_json::Value]) -> String {
533 for message in messages.iter().rev() {
534 let Some(content) = message.get("content") else {
535 continue;
536 };
537 let mut parts = Vec::new();
538 collect_mock_match_strings(content, &mut parts);
539 let text = parts.join("\n");
540 if !text.trim().is_empty() {
541 return text;
542 }
543 }
544 String::new()
545}
546
547fn mock_prompt_cache_key(
548 model: &str,
549 messages: &[serde_json::Value],
550 system: Option<&str>,
551) -> String {
552 serde_json::to_string(&serde_json::json!({
553 "model": model,
554 "system": system,
555 "messages": messages,
556 }))
557 .unwrap_or_default()
558}
559
560fn apply_mock_prompt_cache(result: &mut LlmResult, cache_key: &str) {
561 if result.cache_read_tokens > 0 || result.cache_write_tokens > 0 {
562 return;
563 }
564 let cache_tokens = result.input_tokens.max(0);
565 if cache_tokens == 0 {
566 return;
567 }
568 let cache_hit = LLM_PROMPT_CACHE.with(|cache| {
569 let mut cache = cache.borrow_mut();
570 if cache.contains(cache_key) {
571 true
572 } else {
573 cache.insert(cache_key.to_string());
574 false
575 }
576 });
577 if cache_hit {
578 result.cache_read_tokens = cache_tokens;
579 } else {
580 result.cache_write_tokens = cache_tokens;
581 }
582}
583
584fn mock_error_to_vm_error(err: &MockError) -> VmError {
588 let message = mock_error_message(err);
589 if err.has_provider_envelope() {
590 let classified = super::api::classify_llm_error(err.category.clone(), &message);
591 let mut dict = BTreeMap::new();
592 dict.put_str("category", err.category.as_str());
593 dict.put_str(
594 "kind",
595 err.kind
596 .as_deref()
597 .unwrap_or_else(|| classified.kind.as_str()),
598 );
599 dict.put_str(
600 "reason",
601 err.reason
602 .as_deref()
603 .unwrap_or_else(|| classified.reason.as_str()),
604 );
605 dict.put_str("message", message);
606 if let Some(status) = err.status {
607 dict.insert("status".to_string(), VmValue::Int(i64::from(status)));
608 }
609 if let Some(retry_after_ms) = err.retry_after_ms {
610 dict.insert(
611 "retry_after_ms".to_string(),
612 VmValue::Int(retry_after_ms as i64),
613 );
614 }
615 return VmError::Thrown(VmValue::dict(dict));
616 }
617
618 VmError::CategorizedError {
619 message,
620 category: err.category.clone(),
621 }
622}
623
624fn mock_error_message(err: &MockError) -> String {
625 let Some(ms) = err.retry_after_ms else {
629 return err.message.clone();
630 };
631 if err.has_provider_envelope() {
632 return err.message.clone();
633 }
634 let secs = (ms as f64 / 1000.0).max(0.0);
635 let sep = if err.message.is_empty() || err.message.ends_with('\n') {
636 ""
637 } else {
638 "\n"
639 };
640 format!("{}{sep}retry-after: {secs}\n", err.message)
641}
642
643fn try_match_mock_queue(
647 mocks: &mut Vec<LlmMock>,
648 match_text: &str,
649) -> Option<Result<LlmResult, VmError>> {
650 if let Some(idx) = mocks.iter().position(|m| m.match_pattern.is_none()) {
651 let mock = mocks.remove(idx);
652 return Some(match &mock.error {
653 Some(err) => Err(mock_error_to_vm_error(err)),
654 None => Ok(build_mock_result(&mock, match_text.len())),
655 });
656 }
657
658 for idx in 0..mocks.len() {
659 let mock = &mocks[idx];
660 if let Some(ref pattern) = mock.match_pattern {
661 if mock_glob_match(pattern, match_text) {
662 if mock.consume_on_match {
663 let mock = mocks.remove(idx);
664 return Some(match &mock.error {
665 Some(err) => Err(mock_error_to_vm_error(err)),
666 None => Ok(build_mock_result(&mock, match_text.len())),
667 });
668 }
669 return Some(match &mock.error {
670 Some(err) => Err(mock_error_to_vm_error(err)),
671 None => Ok(build_mock_result(mock, match_text.len())),
672 });
673 }
674 }
675 }
676
677 None
678}
679
680fn try_match_builtin_mock(match_text: &str) -> Option<Result<LlmResult, VmError>> {
681 LLM_MOCKS.with(|mocks| try_match_mock_queue(&mut mocks.borrow_mut(), match_text))
682}
683
684fn try_match_cli_mock(scope: Option<u64>, match_text: &str) -> Option<Result<LlmResult, VmError>> {
685 let scope = scope?;
686 let mut scopes = cli_llm_mock_scopes();
687 let state = scopes.get_mut(&scope)?;
688 if state.mode != CliLlmMockMode::Replay {
689 return None;
690 }
691 try_match_mock_queue(&mut state.mocks, match_text)
692}
693
694pub(crate) fn record_cli_llm_result(request: &super::api::LlmRequestPayload, result: &LlmResult) {
695 record_unified_tape_llm_call(result);
696 let Some(scope) = request.cli_llm_mock_scope else {
697 return;
698 };
699 let mut scopes = cli_llm_mock_scopes();
700 let Some(state) = scopes.get_mut(&scope) else {
701 return;
702 };
703 if state.mode != CliLlmMockMode::Record {
704 return;
705 }
706 state.recordings.push(LlmMock {
707 text: result.text.clone(),
708 tool_calls: result.tool_calls.clone(),
709 raw_tool_calls: result.raw_tool_calls.clone(),
710 match_pattern: None,
711 consume_on_match: false,
712 input_tokens: Some(result.input_tokens),
713 output_tokens: Some(result.output_tokens),
714 cache_read_tokens: Some(result.cache_read_tokens),
715 cache_write_tokens: Some(result.cache_write_tokens),
716 thinking: result.thinking.clone(),
717 thinking_summary: result.thinking_summary.clone(),
718 stop_reason: result.stop_reason.clone(),
719 model: result.model.clone(),
720 provider: Some(result.provider.clone()),
721 blocks: Some(result.blocks.clone()),
722 logprobs: result.logprobs.clone(),
723 error: None,
724 stream_chunks: Vec::new(),
725 });
726}
727
728fn record_unified_tape_llm_call(result: &LlmResult) {
735 if crate::testbench::tape::active_recorder().is_none() {
736 return;
737 }
738 let response_json = serde_json::to_vec(result).unwrap_or_else(|_| Vec::new());
739 let request_digest = LLM_MOCK_CALLS
740 .with(|calls| calls.borrow().last().cloned())
741 .map(|call| {
742 let mut request = serde_json::Map::new();
743 request.insert("messages".to_string(), serde_json::json!(call.messages));
744 request.insert("system".to_string(), serde_json::json!(call.system));
745 request.insert("tools".to_string(), serde_json::json!(call.tools));
746 request.insert(
747 "tool_choice".to_string(),
748 serde_json::json!(call.tool_choice),
749 );
750 request.insert("thinking".to_string(), serde_json::json!(call.thinking));
751 request.insert("model".to_string(), serde_json::json!(result.model));
752 if call.api_mode != "chat_completions" {
753 request.insert("api_mode".to_string(), serde_json::json!(call.api_mode));
754 }
755 if call.provider_tools.is_some() {
756 request.insert(
757 "provider_tools".to_string(),
758 serde_json::json!(call.provider_tools),
759 );
760 }
761 if call
762 .output_format
763 .get("kind")
764 .and_then(|value| value.as_str())
765 != Some("text")
766 {
767 request.insert(
768 "output_format".to_string(),
769 serde_json::json!(call.output_format),
770 );
771 }
772 if call.previous_response_id.is_some() {
773 request.insert(
774 "previous_response_id".to_string(),
775 serde_json::json!(call.previous_response_id),
776 );
777 }
778 if call.store.is_some() {
779 request.insert("store".to_string(), serde_json::json!(call.store));
780 }
781 if call.background.is_some() {
782 request.insert("background".to_string(), serde_json::json!(call.background));
783 }
784 if call.truncation.is_some() {
785 request.insert("truncation".to_string(), serde_json::json!(call.truncation));
786 }
787 if call.compact.is_some() {
788 request.insert("compact".to_string(), serde_json::json!(call.compact));
789 }
790 if call.include.is_some() {
791 request.insert("include".to_string(), serde_json::json!(call.include));
792 }
793 if call.max_tool_calls.is_some() {
794 request.insert(
795 "max_tool_calls".to_string(),
796 serde_json::json!(call.max_tool_calls),
797 );
798 }
799 let serialized =
800 serde_json::to_vec(&serde_json::Value::Object(request)).unwrap_or_default();
801 crate::testbench::tape::content_hash(&serialized)
802 })
803 .unwrap_or_else(|| {
804 crate::testbench::tape::content_hash(result.text.as_bytes())
807 });
808 crate::testbench::tape::with_active_recorder(|recorder| {
809 let response = recorder.payload_from_bytes(response_json);
810 Some(crate::testbench::tape::TapeRecordKind::LlmCall {
811 request_digest,
812 response,
813 })
814 });
815}
816
817fn unmatched_cli_prompt_error(match_text: &str) -> VmError {
818 let mut snippet: String = match_text.chars().take(200).collect();
819 if match_text.chars().count() > 200 {
820 snippet.push_str("...");
821 }
822 VmError::Runtime(format!("No --llm-mock fixture matched prompt: {snippet:?}"))
823}
824
825pub fn set_replay_mode(mode: LlmReplayMode, fixture_dir: &str) {
827 LLM_REPLAY_MODE.with(|v| *v.borrow_mut() = mode);
828 LLM_FIXTURE_DIR.with(|v| *v.borrow_mut() = fixture_dir.to_string());
829}
830
831pub(crate) fn get_replay_mode() -> LlmReplayMode {
832 LLM_REPLAY_MODE.with(|v| *v.borrow())
833}
834
835pub(crate) fn get_fixture_dir() -> String {
836 LLM_FIXTURE_DIR.with(|v| v.borrow().clone())
837}
838
839pub(crate) fn fixture_hash(
841 model: &str,
842 messages: &[serde_json::Value],
843 system: Option<&str>,
844) -> String {
845 use std::hash::{Hash, Hasher};
846 let mut hasher = std::collections::hash_map::DefaultHasher::new();
847 model.hash(&mut hasher);
848 serde_json::to_string(messages)
850 .unwrap_or_default()
851 .hash(&mut hasher);
852 system.hash(&mut hasher);
853 format!("{:016x}", hasher.finish())
854}
855
856pub(crate) fn save_fixture(hash: &str, result: &LlmResult) {
857 let dir = get_fixture_dir();
858 if dir.is_empty() {
859 return;
860 }
861 let _ = std::fs::create_dir_all(&dir);
862 let path = format!("{dir}/{hash}.json");
863 let json = serde_json::json!({
864 "text": result.text,
865 "tool_calls": result.tool_calls,
866 "raw_tool_calls": result.raw_tool_calls,
867 "input_tokens": result.input_tokens,
868 "output_tokens": result.output_tokens,
869 "cache_read_tokens": result.cache_read_tokens,
870 "cache_write_tokens": result.cache_write_tokens,
871 "cache_creation_input_tokens": result.cache_write_tokens,
872 "model": result.model,
873 "provider": result.provider,
874 "thinking": result.thinking,
875 "thinking_summary": result.thinking_summary,
876 "stop_reason": result.stop_reason,
877 "blocks": result.blocks,
878 "logprobs": result.logprobs,
879 });
880 let _ = std::fs::write(
881 &path,
882 serde_json::to_string_pretty(&json).unwrap_or_default(),
883 );
884}
885
886pub(crate) fn load_fixture(hash: &str) -> Option<LlmResult> {
887 let dir = get_fixture_dir();
888 if dir.is_empty() {
889 return None;
890 }
891 let path = format!("{dir}/{hash}.json");
892 let content = std::fs::read_to_string(&path).ok()?;
893 let json: serde_json::Value = serde_json::from_str(&content).ok()?;
894 Some(LlmResult {
895 served_fast: false,
896 text: json["text"].as_str().unwrap_or("").to_string(),
897 tool_calls: json["tool_calls"].as_array().cloned().unwrap_or_default(),
898 raw_tool_calls: RawProviderToolCall::array_from_value(&json["raw_tool_calls"]).ok()?,
899 input_tokens: json["input_tokens"].as_i64().unwrap_or(0),
900 output_tokens: json["output_tokens"].as_i64().unwrap_or(0),
901 cache_read_tokens: json["cache_read_tokens"].as_i64().unwrap_or(0),
902 cache_write_tokens: json["cache_write_tokens"]
903 .as_i64()
904 .or_else(|| json["cache_creation_input_tokens"].as_i64())
905 .unwrap_or(0),
906 cache_supported: json["cache_supported"].as_bool().unwrap_or(true),
907 model: json["model"].as_str().unwrap_or("").to_string(),
908 provider: json["provider"].as_str().unwrap_or("mock").to_string(),
909 thinking: json["thinking"].as_str().map(|s| s.to_string()),
910 thinking_summary: json["thinking_summary"].as_str().map(|s| s.to_string()),
911 stop_reason: json["stop_reason"].as_str().map(|s| s.to_string()),
912 blocks: json["blocks"].as_array().cloned().unwrap_or_default(),
913 logprobs: json["logprobs"].as_array().cloned().unwrap_or_default(),
914 telemetry: serde_json::from_value(json["telemetry"].clone()).unwrap_or_default(),
915 })
916}
917
918fn mock_required_args(tool_schema: &serde_json::Value) -> serde_json::Value {
922 let mut args = serde_json::Map::new();
923 let input_schema = tool_schema
927 .get("input_schema")
928 .or_else(|| tool_schema.get("inputSchema"))
929 .or_else(|| {
930 tool_schema
931 .get("function")
932 .and_then(|f| f.get("parameters"))
933 })
934 .or_else(|| tool_schema.get("parameters"));
935 let Some(schema) = input_schema else {
936 return serde_json::Value::Object(args);
937 };
938 let required: std::collections::BTreeSet<String> = schema
939 .get("required")
940 .and_then(|r| r.as_array())
941 .map(|arr| {
942 arr.iter()
943 .filter_map(|v| v.as_str().map(|s| s.to_string()))
944 .collect()
945 })
946 .unwrap_or_default();
947 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
948 for (name, prop) in props {
949 if !required.contains(name) {
950 continue;
951 }
952 let ty = prop
953 .get("type")
954 .and_then(|t| t.as_str())
955 .unwrap_or("string");
956 let placeholder = match ty {
957 "integer" => serde_json::json!(0),
958 "number" => serde_json::json!(0.0),
959 "boolean" => serde_json::json!(false),
960 "array" => serde_json::json!([]),
961 "object" => serde_json::json!({}),
962 _ => serde_json::json!(""),
963 };
964 args.insert(name.clone(), placeholder);
965 }
966 }
967 serde_json::Value::Object(args)
968}
969
970fn mock_tool_name(tool: &serde_json::Value) -> Option<&str> {
971 tool.get("name")
972 .or_else(|| {
973 tool.get("function")
974 .and_then(|function| function.get("name"))
975 })
976 .and_then(|name| name.as_str())
977}
978
979fn mock_auto_tool_candidate(tools: &[serde_json::Value]) -> Option<&serde_json::Value> {
980 tools
981 .iter()
982 .find(|tool| mock_tool_name(tool) != Some("agent_await_resumption"))
983}
984
985pub(crate) fn mock_llm_response(
990 request: &super::api::LlmRequestPayload,
991) -> Result<LlmResult, VmError> {
992 record_llm_mock_call(request);
993 set_mock_stream_chunks(None);
996
997 let messages = &request.messages;
998 let system = request.system.as_deref();
999 let match_text = mock_match_text(messages);
1000 let prompt_text = mock_last_prompt_text(messages);
1001 let cache_key = mock_prompt_cache_key(&request.model, messages, system);
1002
1003 if let Some(matched) = try_match_cli_mock(request.cli_llm_mock_scope, &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 let Some(matched) = try_match_builtin_mock(&match_text) {
1013 return matched.map(|mut result| {
1014 if request.cache {
1015 apply_mock_prompt_cache(&mut result, &cache_key);
1016 }
1017 result
1018 });
1019 }
1020
1021 if cli_llm_mock_replay_active_for_scope(request.cli_llm_mock_scope) {
1022 return Err(unmatched_cli_prompt_error(&match_text));
1023 }
1024
1025 if let Some(tools) = request.native_tools.as_deref() {
1028 if let Some(first_tool) = mock_auto_tool_candidate(tools) {
1029 let tool_name = mock_tool_name(first_tool).unwrap_or("unknown");
1030 let mock_args = mock_required_args(first_tool);
1031 let mut result = LlmResult {
1032 served_fast: false,
1033 text: String::new(),
1034 tool_calls: vec![serde_json::json!({
1035 "id": "mock_call_1",
1036 "type": "tool_call",
1037 "name": tool_name,
1038 "arguments": mock_args
1039 })],
1040 raw_tool_calls: Vec::new(),
1041 input_tokens: prompt_text.len() as i64,
1042 output_tokens: 20,
1043 cache_read_tokens: 0,
1044 cache_write_tokens: 0,
1045 cache_supported: true,
1046 model: request.model.clone(),
1047 provider: "mock".to_string(),
1048 thinking: None,
1049 thinking_summary: None,
1050 stop_reason: None,
1051 blocks: vec![serde_json::json!({
1052 "type": "tool_call",
1053 "id": "mock_call_1",
1054 "name": tool_name,
1055 "arguments": mock_args,
1056 "visibility": "internal",
1057 })],
1058 logprobs: Vec::new(),
1059 telemetry: ProviderTelemetry::default(),
1060 };
1061 if request.cache {
1062 apply_mock_prompt_cache(&mut result, &cache_key);
1063 }
1064 return Ok(result);
1065 }
1066 }
1067
1068 let tagged_done = system.is_some_and(|s| s.contains("<done>"));
1073
1074 let prose_body = if prompt_text.is_empty() {
1075 "Mock LLM response".to_string()
1076 } else {
1077 let word_count = prompt_text.split_whitespace().count();
1078 format!(
1079 "Mock response to {word_count}-word prompt: {}",
1080 prompt_text.chars().take(100).collect::<String>()
1081 )
1082 };
1083 let response = if tagged_done {
1084 format!("<assistant_prose>{prose_body}</assistant_prose>\n<done>##DONE##</done>")
1085 } else {
1086 prose_body
1087 };
1088
1089 let mut result = LlmResult {
1090 served_fast: false,
1091 text: response.clone(),
1092 tool_calls: vec![],
1093 raw_tool_calls: Vec::new(),
1094 input_tokens: prompt_text.len() as i64,
1095 output_tokens: 30,
1096 cache_read_tokens: 0,
1097 cache_write_tokens: 0,
1098 cache_supported: true,
1099 model: request.model.clone(),
1100 provider: "mock".to_string(),
1101 thinking: None,
1102 thinking_summary: None,
1103 stop_reason: None,
1104 blocks: vec![serde_json::json!({
1105 "type": "output_text",
1106 "text": response,
1107 "visibility": "public",
1108 })],
1109 logprobs: Vec::new(),
1110 telemetry: ProviderTelemetry::default(),
1111 };
1112 if request.cache {
1113 apply_mock_prompt_cache(&mut result, &cache_key);
1114 }
1115 Ok(result)
1116}
1117
1118pub fn drain_tool_recordings() -> Vec<ToolCallRecord> {
1120 TOOL_RECORDINGS.with(|v| std::mem::take(&mut *v.borrow_mut()))
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use crate::llm::api::LlmRequestPayload;
1127
1128 fn text_mock(text: &str) -> LlmMock {
1129 LlmMock {
1130 text: text.to_string(),
1131 tool_calls: Vec::new(),
1132 raw_tool_calls: Vec::new(),
1133 match_pattern: None,
1134 consume_on_match: false,
1135 input_tokens: None,
1136 output_tokens: None,
1137 cache_read_tokens: None,
1138 cache_write_tokens: None,
1139 thinking: None,
1140 thinking_summary: None,
1141 stop_reason: None,
1142 model: "fixture-model".to_string(),
1143 provider: None,
1144 blocks: None,
1145 logprobs: Vec::new(),
1146 error: None,
1147 stream_chunks: Vec::new(),
1148 }
1149 }
1150
1151 #[test]
1152 fn build_mock_result_surfaces_fixture_tool_calls() {
1153 let mock = crate::llm::jsonl::parse_llm_mock_value(&serde_json::json!({
1155 "match": "*",
1156 "consume_match": true,
1157 "tool_calls": [{"name": "ask_user", "arguments": {"question": "Which?"}}]
1158 }))
1159 .expect("parse mock");
1160 assert!(
1161 !mock.tool_calls.is_empty(),
1162 "fixture tool_calls must parse into the mock: {:?}",
1163 mock.tool_calls
1164 );
1165 let result = build_mock_result(&mock, 10);
1166 assert!(
1167 !result.tool_calls.is_empty(),
1168 "build_mock_result must surface tool_calls: {:?}",
1169 result.tool_calls
1170 );
1171 assert_eq!(result.tool_calls[0]["name"], "ask_user");
1172 }
1173
1174 #[test]
1175 fn cli_llm_mock_replay_scope_survives_provider_worker_thread() {
1176 reset_llm_mock_state();
1177 install_cli_llm_mocks(vec![text_mock("cross-thread replay")]);
1178 let request = LlmRequestPayload::from(&crate::llm::api::options::base_opts("anthropic"));
1179
1180 assert!(request.cli_llm_mock_scope.is_some());
1181 assert!(crate::llm::providers::MockProvider::should_intercept_request(&request));
1182
1183 let result = std::thread::spawn(move || {
1184 assert!(crate::llm::providers::MockProvider::should_intercept_request(&request));
1185 mock_llm_response(&request)
1186 })
1187 .join()
1188 .expect("provider worker thread")
1189 .expect("mock response");
1190
1191 assert_eq!(result.text, "cross-thread replay");
1192 clear_cli_llm_mock_mode();
1193 }
1194
1195 #[test]
1196 fn cli_llm_mock_record_scope_collects_provider_worker_thread_results() {
1197 reset_llm_mock_state();
1198 enable_cli_llm_mock_recording();
1199 let request = LlmRequestPayload::from(&crate::llm::api::options::base_opts("anthropic"));
1200 let result = build_mock_result(&text_mock("cross-thread record"), 7);
1201
1202 assert!(request.cli_llm_mock_scope.is_some());
1203 std::thread::spawn(move || record_cli_llm_result(&request, &result))
1204 .join()
1205 .expect("provider worker thread");
1206
1207 let recordings = take_cli_llm_recordings();
1208 assert_eq!(recordings.len(), 1);
1209 assert_eq!(recordings[0].text, "cross-thread record");
1210 clear_cli_llm_mock_mode();
1211 }
1212
1213 #[test]
1214 fn cli_mock_native_tool_calls_reach_the_live_result() {
1215 reset_llm_mock_state();
1220 let mocks = vec![crate::llm::jsonl::parse_llm_mock_value(&serde_json::json!({
1221 "match": "*",
1222 "consume_match": true,
1223 "tool_calls": [{"name": "ask_user", "arguments": {"question": "Which?"}}]
1224 }))
1225 .expect("parse mock")];
1226 install_cli_llm_mocks(mocks);
1227 let request = LlmRequestPayload::from(&crate::llm::api::options::base_opts("fixture"));
1228 assert!(
1229 request.cli_llm_mock_scope.is_some(),
1230 "cli mock scope must be active"
1231 );
1232 let result = mock_llm_response(&request).expect("mock response");
1233 clear_cli_llm_mock_mode();
1234 assert!(
1235 !result.tool_calls.is_empty(),
1236 "CLI mock native tool_calls must reach the live result: text={:?} tool_calls={:?}",
1237 result.text,
1238 result.tool_calls
1239 );
1240 }
1241}