1use std::collections::HashMap;
2use std::fs::OpenOptions;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::Duration;
7
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use tokio::net::TcpListener;
10use tokio::task::JoinHandle;
11
12use ai_agents_core::{
13 ChatMessage, LLMChunk, LLMConfig, LLMError, LLMFeature, LLMProvider, LLMResponse, Tool,
14 ToolExecutionContext, ToolPolicyBindings, ToolResult,
15};
16use ai_agents_llm::providers::{ProviderType, UnifiedLLMProvider};
17use ai_agents_llm::{FinishReason, LLMRegistry};
18use ai_agents_runtime::spec::AgentSpec;
19use ai_agents_tools::{
20 CommandResponse, DiagnosticItem, DiagnosticsProvider, StaticCommandRunner,
21 StaticDiagnosticsProvider, ToolRegistry, UnavailableDiagnosticsProvider,
22 create_builtin_registry,
23};
24use async_trait::async_trait;
25use futures::Stream;
26use parking_lot::Mutex;
27use serde::{Deserialize, Serialize};
28use serde_json::{Value, json};
29use sha2::{Digest, Sha256};
30
31use crate::evidence::{ToolExecutionRecord, ToolExecutionSource};
32use crate::{EvalError, Result};
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct FixturesConfig {
37 #[serde(default)]
39 pub context: Option<Value>,
40 #[serde(default)]
42 pub context_file: Option<PathBuf>,
43 #[serde(default)]
45 pub tools: HashMap<String, ToolMockConfig>,
46 #[serde(default)]
48 pub llm: LlmFixtureConfig,
49 #[serde(default)]
51 pub mock_server: Option<MockServerConfig>,
52 #[serde(default)]
54 pub diagnostics: Option<DiagnosticsFixtureConfig>,
55 #[serde(default)]
57 pub commands: Option<CommandsFixtureConfig>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DiagnosticsFixtureConfig {
63 #[serde(default = "default_true")]
65 pub available: bool,
66 #[serde(default)]
68 pub items: Vec<DiagnosticItem>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CommandsFixtureConfig {
74 #[serde(default = "default_true")]
76 pub available: bool,
77 #[serde(default)]
79 pub entries: Vec<CommandFixtureEntry>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CommandFixtureEntry {
85 #[serde(default)]
87 pub argv: Vec<String>,
88 #[serde(default)]
90 pub response: CommandResponse,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ToolMockConfig {
96 #[serde(default = "default_true")]
98 pub success: bool,
99 #[serde(default)]
101 pub output: Value,
102}
103
104impl Default for ToolMockConfig {
105 fn default() -> Self {
106 Self {
107 success: true,
108 output: Value::Null,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct LlmFixtureConfig {
116 #[serde(default)]
118 pub mode: LlmFixtureMode,
119 #[serde(default)]
121 pub cassette: Option<PathBuf>,
122 #[serde(default)]
124 pub responses: Vec<String>,
125 #[serde(default)]
127 pub responses_by_alias: HashMap<String, Vec<String>>,
128 #[serde(default)]
130 pub errors_by_alias: HashMap<String, String>,
131 #[serde(default)]
133 pub delays_by_alias: HashMap<String, u64>,
134}
135
136#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139pub enum LlmFixtureMode {
140 #[default]
141 Real,
142 Mock,
143 Replay,
144 Record,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149pub struct MockServerConfig {
150 #[serde(default)]
152 pub enabled: bool,
153 #[serde(default)]
155 pub port: Option<u16>,
156 #[serde(default)]
158 pub routes: Vec<Value>,
159}
160
161#[derive(Debug, Clone, Deserialize)]
163struct MockRoute {
164 method: String,
166 path: String,
168 #[serde(default = "default_status")]
170 status: u16,
171 #[serde(default)]
173 headers: HashMap<String, String>,
174 #[serde(default)]
176 body: Value,
177}
178
179pub struct MockServerHandle {
181 base_url: String,
183 task: JoinHandle<()>,
185}
186
187impl MockServerHandle {
188 pub fn context(&self) -> HashMap<String, Value> {
189 let mut context = HashMap::new();
190 context.insert(
191 "mock_server".to_string(),
192 serde_json::json!({"base_url": self.base_url}),
193 );
194 context
195 }
196}
197
198impl Drop for MockServerHandle {
199 fn drop(&mut self) {
200 self.task.abort();
201 }
202}
203
204#[derive(Clone, Default)]
206pub struct RecordingToolLog {
207 inner: Arc<Mutex<Vec<ToolExecutionRecord>>>,
209}
210
211impl From<&ai_agents_core::ToolCallSource> for ToolExecutionSource {
212 fn from(source: &ai_agents_core::ToolCallSource) -> Self {
213 match source {
214 ai_agents_core::ToolCallSource::Model => Self::Llm,
215 ai_agents_core::ToolCallSource::Skill { .. } => Self::Skill,
216 ai_agents_core::ToolCallSource::StateAction { .. } => Self::StateAction,
217 ai_agents_core::ToolCallSource::Orchestration => Self::Orchestration,
218 ai_agents_core::ToolCallSource::Spawner => Self::Spawner,
219 ai_agents_core::ToolCallSource::EvalFixture => Self::Mock,
220 ai_agents_core::ToolCallSource::Plan { .. }
221 | ai_agents_core::ToolCallSource::Fallback { .. }
222 | ai_agents_core::ToolCallSource::Task
223 | ai_agents_core::ToolCallSource::Manual => Self::Llm,
224 }
225 }
226}
227
228fn state_from_source(source: &ai_agents_core::ToolCallSource) -> Option<String> {
229 match source {
230 ai_agents_core::ToolCallSource::StateAction { state, .. } => state.clone(),
231 _ => None,
232 }
233}
234
235impl RecordingToolLog {
236 pub fn new() -> Self {
237 Self::default()
238 }
239
240 pub fn len(&self) -> usize {
241 self.inner.lock().len()
242 }
243
244 pub fn push(&self, record: ToolExecutionRecord) {
245 self.inner.lock().push(record);
246 }
247
248 pub fn push_executor_record(&self, record: &ai_agents_core::ToolExecutionRecord) {
250 let output = record.success.then(|| {
251 serde_json::from_str(&record.output).unwrap_or(Value::String(record.output.clone()))
252 });
253 let mut metadata = record.metadata.clone();
254 metadata.insert("executed".to_string(), Value::Bool(record.executed));
255 metadata.insert(
256 "policy".to_string(),
257 serde_json::to_value(&record.policy).unwrap_or(Value::Null),
258 );
259 metadata.insert(
260 "approval".to_string(),
261 serde_json::to_value(&record.approval).unwrap_or(Value::Null),
262 );
263 metadata.insert("timed_out".to_string(), Value::Bool(record.timed_out));
264 metadata.insert("cancelled".to_string(), Value::Bool(record.cancelled));
265 self.push(ToolExecutionRecord {
266 call_id: record.call_id.clone(),
267 tool_id: record.canonical_id.clone(),
268 requested_name: record.requested_name.clone(),
269 source: ToolExecutionSource::from(&record.source),
270 state: state_from_source(&record.source),
271 actor_id: None,
272 arguments_original: record.arguments.clone(),
273 arguments_executed: record.executed_arguments.clone(),
274 executed: record.executed,
275 success: record.success,
276 output,
277 error: (!record.success).then_some(record.output.clone()),
278 metadata: Some(serde_json::to_value(metadata).unwrap_or(Value::Null)),
279 started_at: record.started_at,
280 duration_ms: record.duration_ms,
281 observability_span_id: None,
282 });
283 }
284
285 pub fn records_since(&self, index: usize) -> Vec<ToolExecutionRecord> {
286 self.inner.lock().iter().skip(index).cloned().collect()
287 }
288}
289
290pub fn resolve_fixture_context(
291 config: &FixturesConfig,
292 base_dir: &Path,
293) -> Result<HashMap<String, Value>> {
294 let mut result = HashMap::new();
295 if let Some(path) = &config.context_file {
296 let resolved = resolve_path(base_dir, path);
297 let content = std::fs::read_to_string(&resolved).map_err(|error| {
298 EvalError::Config(format!(
299 "failed to read context_file '{}': {}",
300 resolved.display(),
301 error
302 ))
303 })?;
304 let value: Value = serde_json::from_str(&content).map_err(|error| {
305 EvalError::Config(format!(
306 "failed to parse context_file '{}': {}",
307 resolved.display(),
308 error
309 ))
310 })?;
311 merge_object_into_map(&mut result, value)?;
312 }
313 if let Some(value) = &config.context {
314 merge_object_into_map(&mut result, value.clone())?;
315 }
316 Ok(result)
317}
318
319fn merge_object_into_map(target: &mut HashMap<String, Value>, value: Value) -> Result<()> {
320 let Value::Object(map) = value else {
321 return Err(EvalError::Config(
322 "fixture context must be a JSON object".into(),
323 ));
324 };
325 for (key, value) in map {
326 target.insert(key, value);
327 }
328 Ok(())
329}
330
331fn resolve_path(base_dir: &Path, path: &Path) -> PathBuf {
332 if path.is_absolute() {
333 path.to_path_buf()
334 } else {
335 base_dir.join(path)
336 }
337}
338
339pub async fn start_mock_server(
340 config: Option<&MockServerConfig>,
341) -> Result<Option<MockServerHandle>> {
342 let Some(config) = config else {
343 return Ok(None);
344 };
345 if !config.enabled {
346 return Ok(None);
347 }
348 let routes = config
349 .routes
350 .iter()
351 .cloned()
352 .map(serde_json::from_value::<MockRoute>)
353 .collect::<std::result::Result<Vec<_>, _>>()?;
354 let port = config.port.unwrap_or(0);
355 let listener = TcpListener::bind(("127.0.0.1", port))
356 .await
357 .map_err(|error| EvalError::Runtime(format!("failed to start mock server: {}", error)))?;
358 let addr = listener.local_addr().map_err(|error| {
359 EvalError::Runtime(format!("failed to read mock server addr: {}", error))
360 })?;
361 let base_url = format!("http://{}", addr);
362 let task = tokio::spawn(async move {
363 loop {
364 let Ok((stream, _)) = listener.accept().await else {
365 break;
366 };
367 let routes = routes.clone();
368 tokio::spawn(async move {
369 let _ = handle_mock_connection(stream, routes).await;
370 });
371 }
372 });
373 Ok(Some(MockServerHandle { base_url, task }))
374}
375
376async fn handle_mock_connection(
377 mut stream: tokio::net::TcpStream,
378 routes: Vec<MockRoute>,
379) -> std::io::Result<()> {
380 let mut buffer = vec![0_u8; 8192];
381 let read = stream.read(&mut buffer).await?;
382 let request = String::from_utf8_lossy(&buffer[..read]);
383 let first_line = request.lines().next().unwrap_or_default();
384 let mut parts = first_line.split_whitespace();
385 let method = parts.next().unwrap_or_default();
386 let path = parts.next().unwrap_or_default();
387 let route = routes
388 .iter()
389 .find(|route| route.method.eq_ignore_ascii_case(method) && route.path == path);
390 let (status, headers, body) = if let Some(route) = route {
391 (
392 route.status,
393 route.headers.clone(),
394 mock_body_to_string(&route.body),
395 )
396 } else {
397 (
398 404,
399 HashMap::new(),
400 serde_json::json!({"error":"not found"}).to_string(),
401 )
402 };
403 let reason = match status {
404 200 => "OK",
405 201 => "Created",
406 204 => "No Content",
407 400 => "Bad Request",
408 401 => "Unauthorized",
409 403 => "Forbidden",
410 404 => "Not Found",
411 500 => "Internal Server Error",
412 _ => "OK",
413 };
414 let mut response = format!(
415 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n",
416 status,
417 reason,
418 body.len()
419 );
420 for (key, value) in headers {
421 response.push_str(&format!("{}: {}\r\n", key, value));
422 }
423 response.push_str("\r\n");
424 response.push_str(&body);
425 stream.write_all(response.as_bytes()).await?;
426 Ok(())
427}
428
429fn mock_body_to_string(body: &Value) -> String {
430 if let Some(text) = body.as_str() {
431 text.to_string()
432 } else {
433 serde_json::to_string(body).unwrap_or_else(|_| "null".to_string())
434 }
435}
436
437pub fn build_tool_registry(
438 fixtures: &FixturesConfig,
439 log: RecordingToolLog,
440) -> Result<ToolRegistry> {
441 let builtin = create_builtin_registry();
442 let mut registry = ToolRegistry::new();
443 let provider: Arc<dyn DiagnosticsProvider> = if let Some(diagnostics) = &fixtures.diagnostics {
444 Arc::new(StaticDiagnosticsProvider::with_availability(
445 diagnostics.items.clone(),
446 diagnostics.available,
447 ))
448 } else {
449 Arc::new(UnavailableDiagnosticsProvider)
450 };
451 builtin.set_diagnostics_provider(provider.clone());
452 registry.set_diagnostics_provider(provider);
453
454 if let Some(commands) = &fixtures.commands {
455 let responses = commands
456 .entries
457 .iter()
458 .map(|entry| (entry.argv.clone(), entry.response.clone()))
459 .collect();
460 let runner = Arc::new(StaticCommandRunner::with_availability(
461 responses,
462 commands.available,
463 ));
464 builtin.set_command_runner(runner.clone());
465 registry.set_command_runner(runner);
466 }
467
468 for (id, mock) in &fixtures.tools {
469 registry
470 .register(Arc::new(RecordingTool::new(
471 Arc::new(MockTool::new(id.clone(), mock.clone())),
472 log.clone(),
473 ToolExecutionSource::Mock,
474 )))
475 .map_err(|error| EvalError::Config(error.to_string()))?;
476 }
477
478 for id in builtin.list_ids() {
479 if fixtures.tools.contains_key(&id) {
480 continue;
481 }
482 if let Some(tool) = builtin.get(&id) {
483 registry
484 .register(Arc::new(RecordingTool::new(
485 tool,
486 log.clone(),
487 ToolExecutionSource::Llm,
488 )))
489 .map_err(|error| EvalError::Config(error.to_string()))?;
490 }
491 }
492
493 Ok(registry)
494}
495
496struct MockTool {
498 id: String,
500 config: ToolMockConfig,
502}
503
504impl MockTool {
505 fn new(id: String, config: ToolMockConfig) -> Self {
506 Self { id, config }
507 }
508}
509
510#[async_trait]
511impl Tool for MockTool {
512 fn id(&self) -> &str {
513 &self.id
514 }
515
516 fn name(&self) -> &str {
517 &self.id
518 }
519
520 fn description(&self) -> &str {
521 "Evaluation mock tool"
522 }
523
524 fn input_schema(&self) -> Value {
525 serde_json::json!({"type": "object"})
526 }
527
528 async fn execute(
529 &self,
530 _args: Value,
531 _ctx: ai_agents_core::ToolExecutionContext,
532 ) -> ToolResult {
533 let output = if self.config.output.is_string() {
534 self.config.output.as_str().unwrap_or_default().to_string()
535 } else {
536 serde_json::to_string(&self.config.output).unwrap_or_else(|_| "null".to_string())
537 };
538 ToolResult {
539 success: self.config.success,
540 output,
541 metadata: None,
542 }
543 }
544}
545
546struct RecordingTool {
548 inner: Arc<dyn Tool>,
550}
551
552impl RecordingTool {
553 fn new(inner: Arc<dyn Tool>, _log: RecordingToolLog, _source: ToolExecutionSource) -> Self {
554 Self { inner }
555 }
556}
557
558#[async_trait]
559impl Tool for RecordingTool {
560 fn id(&self) -> &str {
561 self.inner.id()
562 }
563
564 fn name(&self) -> &str {
565 self.inner.name()
566 }
567
568 fn description(&self) -> &str {
569 self.inner.description()
570 }
571
572 fn input_schema(&self) -> Value {
573 self.inner.input_schema()
574 }
575
576 fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
577 self.inner.safety_metadata()
578 }
579
580 fn classify_call(&self, args: &Value) -> ai_agents_core::ToolCallClassification {
581 self.inner.classify_call(args)
582 }
583
584 fn policy_bindings(&self) -> ToolPolicyBindings {
585 self.inner.policy_bindings()
586 }
587
588 async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
589 self.inner.execute(args, ctx).await
590 }
591}
592
593pub fn build_llm_registry(
594 spec: &AgentSpec,
595 fixtures: &LlmFixtureConfig,
596 base_dir: &Path,
597) -> Result<(LLMRegistry, Option<Arc<dyn LLMProvider>>)> {
598 let mut registry = LLMRegistry::new();
599 let aliases = if spec.llms.is_empty() {
600 vec![(
601 "default".to_string(),
602 spec.llm.as_config().cloned().unwrap_or_default(),
603 )]
604 } else {
605 spec.llms
606 .iter()
607 .map(|(alias, config)| (alias.clone(), config.clone()))
608 .collect()
609 };
610
611 let cassette_records = load_cassette_records(fixtures, base_dir)?;
612 let mut judge_provider = None;
613
614 for (alias, config) in aliases {
615 let fixture_responses = load_fixture_responses_for_alias(fixtures, base_dir, &alias)?;
616 let fixture_error = fixtures.errors_by_alias.get(&alias).cloned();
617 let fixture_delay_ms = fixtures.delays_by_alias.get(&alias).copied().unwrap_or(0);
618 let provider = match fixtures.mode {
619 LlmFixtureMode::Mock => Arc::new(SequenceLLMProvider::new(
620 alias.clone(),
621 fixture_responses.clone(),
622 fixture_error,
623 fixture_delay_ms,
624 )) as Arc<dyn LLMProvider>,
625 LlmFixtureMode::Replay => Arc::new(ReplayLLMProvider::new(
626 alias.clone(),
627 config.model.clone(),
628 cassette_records.clone(),
629 fixture_responses.clone(),
630 fixture_delay_ms,
631 )) as Arc<dyn LLMProvider>,
632 LlmFixtureMode::Real => build_real_provider(&config)?,
633 LlmFixtureMode::Record => {
634 let inner = build_real_provider(&config)?;
635 let path = fixtures
636 .cassette
637 .as_ref()
638 .map(|p| resolve_path(base_dir, p))
639 .unwrap_or_else(|| base_dir.join("llm_cassette.jsonl"));
640 Arc::new(RecordingLLMProvider::new(
641 inner,
642 alias.clone(),
643 config.model.clone(),
644 path,
645 )) as Arc<dyn LLMProvider>
646 }
647 };
648 if judge_provider.is_none() {
649 judge_provider = Some(provider.clone());
650 }
651 registry.register(alias, provider);
652 }
653
654 let default_alias = spec.llm.get_default_alias();
655 registry.set_default(default_alias);
656 if let Some(router) = spec.llm.get_router_alias() {
657 registry.set_router(router);
658 }
659
660 Ok((registry, judge_provider))
661}
662
663fn build_real_provider(
664 config: &ai_agents_runtime::spec::LLMConfig,
665) -> Result<Arc<dyn LLMProvider>> {
666 use std::str::FromStr;
667 let provider_type = ProviderType::from_str(&config.provider)
668 .map_err(|error| EvalError::Config(error.to_string()))?;
669 let core_config = ai_agents_core::LLMConfig {
670 temperature: Some(config.temperature),
671 max_tokens: Some(config.max_tokens),
672 top_p: config.top_p,
673 top_k: None,
674 frequency_penalty: None,
675 presence_penalty: None,
676 stop_sequences: None,
677 timeout_seconds: config.timeout_seconds,
678 reasoning: config.reasoning,
679 reasoning_effort: config.reasoning_effort.clone(),
680 reasoning_budget_tokens: config.reasoning_budget_tokens,
681 extra: config.extra.clone(),
682 };
683 let base_url = config.base_url.clone().or_else(|| {
684 config
685 .extra
686 .get("base_url")
687 .and_then(Value::as_str)
688 .map(str::to_string)
689 });
690 let api_key = config
691 .api_key_env
692 .as_ref()
693 .and_then(|env| std::env::var(env).ok());
694 let mut provider = UnifiedLLMProvider::from_spec_config(
695 provider_type,
696 &config.model,
697 api_key,
698 base_url,
699 core_config,
700 )
701 .map_err(|error| EvalError::Runtime(error.to_string()))?;
702 if let Some(value) = config.function_calling {
703 provider = provider.with_feature_override(LLMFeature::FunctionCalling, value);
704 }
705 if let Some(value) = config.vision {
706 provider = provider.with_feature_override(LLMFeature::Vision, value);
707 }
708 if let Some(value) = config.json_mode {
709 provider = provider.with_feature_override(LLMFeature::JsonMode, value);
710 }
711 Ok(Arc::new(provider))
712}
713
714fn load_fixture_responses_for_alias(
715 config: &LlmFixtureConfig,
716 base_dir: &Path,
717 alias: &str,
718) -> Result<Vec<LLMResponse>> {
719 let configured = config
720 .responses_by_alias
721 .get(alias)
722 .unwrap_or(&config.responses);
723 let mut responses = Vec::new();
724 for content in configured {
725 responses.push(LLMResponse::new(content.clone(), FinishReason::Stop));
726 }
727 for record in load_cassette_records(config, base_dir)? {
728 if record.alias == alias {
729 responses.push(record.response);
730 }
731 }
732 if responses.is_empty() {
733 responses.push(LLMResponse::new("Mock response", FinishReason::Stop));
734 }
735 Ok(responses)
736}
737
738fn load_cassette_records(
739 config: &LlmFixtureConfig,
740 base_dir: &Path,
741) -> Result<Vec<CassetteRecord>> {
742 let mut records = Vec::new();
743 if let Some(path) = &config.cassette {
744 let resolved = resolve_path(base_dir, path);
745 if resolved.exists() {
746 let content = std::fs::read_to_string(&resolved)?;
747 for line in content.lines().filter(|line| !line.trim().is_empty()) {
748 records.push(serde_json::from_str(line)?);
749 }
750 }
751 }
752 Ok(records)
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
757struct CassetteRecord {
758 alias: String,
760 model: String,
762 request_hash: String,
764 #[serde(default)]
766 request_hash_version: Option<String>,
767 response: LLMResponse,
769}
770
771struct SequenceLLMProvider {
773 responses: Arc<Mutex<Vec<LLMResponse>>>,
775 index: Arc<Mutex<usize>>,
777 error: Option<String>,
779 delay_ms: u64,
781}
782
783struct ReplayLLMProvider {
785 alias: String,
787 model: String,
789 records: Arc<Vec<CassetteRecord>>,
791 responses: SequenceLLMProvider,
793 delay_ms: u64,
795}
796
797impl SequenceLLMProvider {
798 fn new(
799 _name: String,
800 responses: Vec<LLMResponse>,
801 error: Option<String>,
802 delay_ms: u64,
803 ) -> Self {
804 Self {
805 responses: Arc::new(Mutex::new(responses)),
806 index: Arc::new(Mutex::new(0)),
807 error,
808 delay_ms,
809 }
810 }
811
812 async fn wait_if_configured(&self) {
813 if self.delay_ms > 0 {
814 tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
815 }
816 }
817
818 fn next_response(&self) -> LLMResponse {
819 let responses = self.responses.lock();
820 let mut index = self.index.lock();
821 let response = responses
822 .get(*index)
823 .cloned()
824 .or_else(|| responses.last().cloned())
825 .unwrap_or_else(|| LLMResponse::new("Mock response", FinishReason::Stop));
826 if *index + 1 < responses.len() {
827 *index += 1;
828 }
829 response
830 }
831}
832
833impl ReplayLLMProvider {
834 fn new(
835 alias: String,
836 model: String,
837 records: Vec<CassetteRecord>,
838 fallback: Vec<LLMResponse>,
839 delay_ms: u64,
840 ) -> Self {
841 Self {
842 alias,
843 model,
844 records: Arc::new(records),
845 responses: SequenceLLMProvider::new(
846 "replay-fallback".to_string(),
847 fallback,
848 None,
849 delay_ms,
850 ),
851 delay_ms,
852 }
853 }
854
855 async fn wait_if_configured(&self) {
856 if self.delay_ms > 0 {
857 tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
858 }
859 }
860
861 fn response_for(&self, messages: &[ChatMessage], config: Option<&LLMConfig>) -> LLMResponse {
862 let request_hash = hash_request(messages, config);
863 if let Some(record) = self.records.iter().find(|record| {
864 record.alias == self.alias
865 && record.request_hash == request_hash
866 && (record.model == self.model || record.model.is_empty())
867 }) {
868 return record.response.clone();
869 }
870 self.responses.next_response()
871 }
872}
873
874#[async_trait]
875impl LLMProvider for ReplayLLMProvider {
876 async fn complete(
877 &self,
878 messages: &[ChatMessage],
879 config: Option<&LLMConfig>,
880 ) -> std::result::Result<LLMResponse, LLMError> {
881 self.wait_if_configured().await;
882 Ok(self.response_for(messages, config))
883 }
884
885 async fn complete_stream(
886 &self,
887 messages: &[ChatMessage],
888 config: Option<&LLMConfig>,
889 ) -> std::result::Result<
890 Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
891 LLMError,
892 > {
893 self.wait_if_configured().await;
894 let response = self.response_for(messages, config);
895 Ok(Box::new(futures::stream::iter(chunks_from_response(
896 response,
897 ))))
898 }
899
900 fn provider_name(&self) -> &str {
901 "eval-replay"
902 }
903
904 fn supports(&self, feature: LLMFeature) -> bool {
905 matches!(feature, LLMFeature::Streaming | LLMFeature::SystemMessages)
906 }
907}
908
909#[async_trait]
910impl LLMProvider for SequenceLLMProvider {
911 async fn complete(
912 &self,
913 _messages: &[ChatMessage],
914 _config: Option<&LLMConfig>,
915 ) -> std::result::Result<LLMResponse, LLMError> {
916 self.wait_if_configured().await;
917 if let Some(error) = &self.error {
918 return Err(LLMError::API {
919 message: error.clone(),
920 status: None,
921 });
922 }
923 Ok(self.next_response())
924 }
925
926 async fn complete_stream(
927 &self,
928 _messages: &[ChatMessage],
929 _config: Option<&LLMConfig>,
930 ) -> std::result::Result<
931 Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
932 LLMError,
933 > {
934 self.wait_if_configured().await;
935 if let Some(error) = &self.error {
936 return Err(LLMError::API {
937 message: error.clone(),
938 status: None,
939 });
940 }
941 let response = self.next_response();
942 Ok(Box::new(futures::stream::iter(chunks_from_response(
943 response,
944 ))))
945 }
946
947 fn provider_name(&self) -> &str {
948 "eval-sequence"
949 }
950
951 fn supports(&self, feature: LLMFeature) -> bool {
952 matches!(feature, LLMFeature::Streaming | LLMFeature::SystemMessages)
953 }
954}
955
956fn chunks_from_response(response: LLMResponse) -> Vec<std::result::Result<LLMChunk, LLMError>> {
957 let deltas = split_stream_content(&response.content);
958 if deltas.is_empty() {
959 return vec![Ok(LLMChunk::final_chunk(
960 "",
961 response.finish_reason,
962 response.usage,
963 ))];
964 }
965 let last_index = deltas.len() - 1;
966 deltas
967 .into_iter()
968 .enumerate()
969 .map(|(index, delta)| {
970 if index == last_index {
971 Ok(LLMChunk::final_chunk(
972 delta,
973 response.finish_reason.clone(),
974 response.usage.clone(),
975 ))
976 } else {
977 Ok(LLMChunk::new(delta, false))
978 }
979 })
980 .collect()
981}
982
983fn split_stream_content(content: &str) -> Vec<String> {
984 if content.is_empty() {
985 return Vec::new();
986 }
987 let words: Vec<&str> = content.split_whitespace().collect();
988 if words.len() > 1 {
989 return words
990 .into_iter()
991 .enumerate()
992 .map(|(index, word)| {
993 if index == 0 {
994 word.to_string()
995 } else {
996 format!(" {}", word)
997 }
998 })
999 .collect();
1000 }
1001 let chars: Vec<char> = content.chars().collect();
1002 if chars.len() <= 1 {
1003 return vec![content.to_string()];
1004 }
1005 let chunk_size = 4.min(chars.len().div_ceil(2));
1006 chars
1007 .chunks(chunk_size)
1008 .map(|chunk| chunk.iter().collect::<String>())
1009 .collect()
1010}
1011
1012struct RecordingLLMProvider {
1014 inner: Arc<dyn LLMProvider>,
1016 alias: String,
1018 model: String,
1020 path: PathBuf,
1022}
1023
1024impl RecordingLLMProvider {
1025 fn new(inner: Arc<dyn LLMProvider>, alias: String, model: String, path: PathBuf) -> Self {
1026 Self {
1027 inner,
1028 alias,
1029 model,
1030 path,
1031 }
1032 }
1033}
1034
1035#[async_trait]
1036impl LLMProvider for RecordingLLMProvider {
1037 async fn complete(
1038 &self,
1039 messages: &[ChatMessage],
1040 config: Option<&LLMConfig>,
1041 ) -> std::result::Result<LLMResponse, LLMError> {
1042 let response = self.inner.complete(messages, config).await?;
1043 let record = CassetteRecord {
1044 alias: self.alias.clone(),
1045 model: self.model.clone(),
1046 request_hash: hash_request(messages, config),
1047 request_hash_version: Some("sha256-v1".to_string()),
1048 response: response.clone(),
1049 };
1050 if let Some(parent) = self.path.parent() {
1051 let _ = std::fs::create_dir_all(parent);
1052 }
1053 if let Ok(mut file) = OpenOptions::new()
1054 .create(true)
1055 .append(true)
1056 .open(&self.path)
1057 {
1058 let _ = writeln!(
1059 file,
1060 "{}",
1061 serde_json::to_string(&record).unwrap_or_default()
1062 );
1063 }
1064 Ok(response)
1065 }
1066
1067 async fn complete_stream(
1068 &self,
1069 messages: &[ChatMessage],
1070 config: Option<&LLMConfig>,
1071 ) -> std::result::Result<
1072 Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
1073 LLMError,
1074 > {
1075 self.inner.complete_stream(messages, config).await
1076 }
1077
1078 fn provider_name(&self) -> &str {
1079 self.inner.provider_name()
1080 }
1081
1082 fn supports(&self, feature: LLMFeature) -> bool {
1083 self.inner.supports(feature)
1084 }
1085}
1086
1087fn hash_request(messages: &[ChatMessage], config: Option<&LLMConfig>) -> String {
1088 let canonical_messages: Vec<Value> = messages
1089 .iter()
1090 .map(|message| {
1091 json!({
1092 "role": format!("{:?}", message.role),
1093 "content": message.content,
1094 "name": message.name,
1095 })
1096 })
1097 .collect();
1098 let canonical = json!({
1099 "version": "sha256-v1",
1100 "messages": canonical_messages,
1101 "config": config,
1102 });
1103 let encoded = serde_json::to_vec(&canonical).unwrap_or_default();
1104 let digest = Sha256::digest(encoded);
1105 format!("sha256-v1:{:x}", digest)
1106}
1107
1108fn default_status() -> u16 {
1109 200
1110}
1111
1112fn default_true() -> bool {
1113 true
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119
1120 #[test]
1121 fn context_file_and_inline_context_merge() {
1122 let dir = std::env::temp_dir().join(format!(
1123 "ai_agents_eval_fixture_test_{}",
1124 uuid::Uuid::new_v4()
1125 ));
1126 std::fs::create_dir_all(&dir).unwrap();
1127 let context_path = dir.join("context.json");
1128 std::fs::write(
1129 &context_path,
1130 r#"{"user":{"tier":"basic"},"channel":"file"}"#,
1131 )
1132 .unwrap();
1133 let config = FixturesConfig {
1134 context: Some(serde_json::json!({"channel":"inline","feature":true})),
1135 context_file: Some(PathBuf::from("context.json")),
1136 ..Default::default()
1137 };
1138 let context = resolve_fixture_context(&config, &dir).unwrap();
1139 assert_eq!(context.get("channel"), Some(&serde_json::json!("inline")));
1140 assert_eq!(context.get("feature"), Some(&serde_json::json!(true)));
1141 assert!(context.get("user").is_some());
1142 let _ = std::fs::remove_dir_all(dir);
1143 }
1144
1145 #[test]
1146 fn mock_streaming_splits_response_into_multiple_chunks() {
1147 let response = LLMResponse::new(
1148 "Streaming hello from the mocked provider.".to_string(),
1149 FinishReason::Stop,
1150 );
1151 let chunks = chunks_from_response(response);
1152 assert!(chunks.len() > 1);
1153 let mut reconstructed = String::new();
1154 for (index, chunk) in chunks.into_iter().enumerate() {
1155 let chunk = chunk.unwrap();
1156 if index == 0 {
1157 assert!(!chunk.is_final);
1158 }
1159 reconstructed.push_str(&chunk.delta);
1160 if chunk.is_final {
1161 assert!(chunk.finish_reason.is_some());
1162 }
1163 }
1164 assert_eq!(reconstructed, "Streaming hello from the mocked provider.");
1165 }
1166
1167 #[test]
1168 fn mock_streaming_splits_single_word_response() {
1169 let chunks = split_stream_content("Hello");
1170 assert!(chunks.len() > 1);
1171 assert_eq!(chunks.join(""), "Hello");
1172 }
1173
1174 #[tokio::test]
1175 async fn mock_server_serves_configured_route() {
1176 let config = MockServerConfig {
1177 enabled: true,
1178 port: None,
1179 routes: vec![serde_json::json!({
1180 "method":"GET",
1181 "path":"/ok",
1182 "status":200,
1183 "body":{"ok":true}
1184 })],
1185 };
1186 let server = start_mock_server(Some(&config)).await.unwrap().unwrap();
1187 let context = server.context();
1188 let base_url = context
1189 .get("mock_server")
1190 .and_then(|value| value.get("base_url"))
1191 .and_then(Value::as_str)
1192 .unwrap()
1193 .trim_start_matches("http://")
1194 .to_string();
1195 let mut stream = tokio::net::TcpStream::connect(base_url).await.unwrap();
1196 stream
1197 .write_all(b"GET /ok HTTP/1.1\r\nHost: localhost\r\n\r\n")
1198 .await
1199 .unwrap();
1200 let mut response = String::new();
1201 stream.read_to_string(&mut response).await.unwrap();
1202 assert!(response.contains("200 OK"));
1203 assert!(response.contains("{\"ok\":true}"));
1204 }
1205}