1use std::fs::File;
2use std::io::{BufRead, BufReader};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use uuid::Uuid;
9
10use crate::config::proxy_home_dir;
11use crate::local_log_store::{LogRetention, append_line};
12use crate::logging::now_ms;
13
14use super::{CodexRelayCapabilitiesResponse, CodexRelayLiveSmokeResponse};
15
16const CODEX_RELAY_EVIDENCE_SCHEMA_VERSION: u32 = 1;
17const CODEX_RELAY_EVIDENCE_FILE: &str = "codex_relay_evidence.jsonl";
18const DEFAULT_CODEX_RELAY_EVIDENCE_MAX_BYTES: u64 = 20 * 1024 * 1024;
19const DEFAULT_CODEX_RELAY_EVIDENCE_MAX_FILES: usize = 10;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum CodexRelayEvidenceKind {
24 CapabilityDiagnostics,
25 LiveSmoke,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct CodexRelayEvidenceEntry {
30 pub schema_version: u32,
31 pub evidence_id: String,
32 pub timestamp_ms: u64,
33 pub source: String,
34 pub kind: CodexRelayEvidenceKind,
35 pub service_name: String,
36 pub station_name: String,
37 pub upstream_index: usize,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub provider_id: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub endpoint_id: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub provider_endpoint_key: Option<String>,
44 pub upstream_base_url: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub model: Option<String>,
47 pub payload: Value,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct CodexRelayEvidenceFilters {
52 pub kind: Option<CodexRelayEvidenceKind>,
53 pub station_name: Option<String>,
54 pub model: Option<String>,
55}
56
57impl CodexRelayEvidenceFilters {
58 pub fn matches(&self, entry: &CodexRelayEvidenceEntry) -> bool {
59 if let Some(kind) = self.kind
60 && entry.kind != kind
61 {
62 return false;
63 }
64 if let Some(station_name) = self.station_name.as_deref()
65 && !contains_ignore_ascii_case(entry.station_name.as_str(), station_name)
66 {
67 return false;
68 }
69 if let Some(model) = self.model.as_deref()
70 && !entry
71 .model
72 .as_deref()
73 .is_some_and(|value| contains_ignore_ascii_case(value, model))
74 {
75 return false;
76 }
77 true
78 }
79}
80
81pub fn codex_relay_evidence_path() -> PathBuf {
82 proxy_home_dir()
83 .join("logs")
84 .join(CODEX_RELAY_EVIDENCE_FILE)
85}
86
87pub fn append_codex_relay_capabilities_evidence(
88 response: &CodexRelayCapabilitiesResponse,
89 source: &str,
90) -> std::io::Result<CodexRelayEvidenceEntry> {
91 let entry = capability_evidence_entry(response, source)?;
92 append_codex_relay_evidence_entry(&codex_relay_evidence_path(), &entry)?;
93 Ok(entry)
94}
95
96pub fn append_codex_relay_live_smoke_evidence(
97 response: &CodexRelayLiveSmokeResponse,
98 source: &str,
99) -> std::io::Result<CodexRelayEvidenceEntry> {
100 let entry = live_smoke_evidence_entry(response, source)?;
101 append_codex_relay_evidence_entry(&codex_relay_evidence_path(), &entry)?;
102 Ok(entry)
103}
104
105pub fn read_recent_codex_relay_evidence(
106 limit: usize,
107 filters: &CodexRelayEvidenceFilters,
108) -> std::io::Result<Vec<CodexRelayEvidenceEntry>> {
109 read_recent_codex_relay_evidence_from_path(&codex_relay_evidence_path(), limit, filters)
110}
111
112fn capability_evidence_entry(
113 response: &CodexRelayCapabilitiesResponse,
114 source: &str,
115) -> std::io::Result<CodexRelayEvidenceEntry> {
116 Ok(CodexRelayEvidenceEntry {
117 schema_version: CODEX_RELAY_EVIDENCE_SCHEMA_VERSION,
118 evidence_id: Uuid::new_v4().to_string(),
119 timestamp_ms: now_ms(),
120 source: normalize_source(source),
121 kind: CodexRelayEvidenceKind::CapabilityDiagnostics,
122 service_name: response.service_name.clone(),
123 station_name: response.station_name.clone(),
124 upstream_index: response.upstream_index,
125 provider_id: response.provider_id.clone(),
126 endpoint_id: response.endpoint_id.clone(),
127 provider_endpoint_key: response.provider_endpoint_key.clone(),
128 upstream_base_url: response.upstream_base_url.clone(),
129 model: response.model.clone(),
130 payload: serde_json::to_value(response).map_err(std::io::Error::other)?,
131 })
132}
133
134fn live_smoke_evidence_entry(
135 response: &CodexRelayLiveSmokeResponse,
136 source: &str,
137) -> std::io::Result<CodexRelayEvidenceEntry> {
138 Ok(CodexRelayEvidenceEntry {
139 schema_version: CODEX_RELAY_EVIDENCE_SCHEMA_VERSION,
140 evidence_id: Uuid::new_v4().to_string(),
141 timestamp_ms: now_ms(),
142 source: normalize_source(source),
143 kind: CodexRelayEvidenceKind::LiveSmoke,
144 service_name: response.service_name.clone(),
145 station_name: response.station_name.clone(),
146 upstream_index: response.upstream_index,
147 provider_id: response.provider_id.clone(),
148 endpoint_id: response.endpoint_id.clone(),
149 provider_endpoint_key: response.provider_endpoint_key.clone(),
150 upstream_base_url: response.upstream_base_url.clone(),
151 model: Some(response.requested_model.clone()),
152 payload: serde_json::to_value(response).map_err(std::io::Error::other)?,
153 })
154}
155
156fn append_codex_relay_evidence_entry(
157 path: &Path,
158 entry: &CodexRelayEvidenceEntry,
159) -> std::io::Result<()> {
160 let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
161 let _guard = match evidence_lock().lock() {
162 Ok(guard) => guard,
163 Err(error) => error.into_inner(),
164 };
165 append_line(path, codex_relay_evidence_retention(), &line)
166}
167
168fn read_recent_codex_relay_evidence_from_path(
169 path: &Path,
170 limit: usize,
171 filters: &CodexRelayEvidenceFilters,
172) -> std::io::Result<Vec<CodexRelayEvidenceEntry>> {
173 read_recent_codex_relay_evidence_from_path_with_retention(
174 path,
175 limit,
176 filters,
177 codex_relay_evidence_retention(),
178 )
179}
180
181fn read_recent_codex_relay_evidence_from_path_with_retention(
182 path: &Path,
183 limit: usize,
184 filters: &CodexRelayEvidenceFilters,
185 retention: LogRetention,
186) -> std::io::Result<Vec<CodexRelayEvidenceEntry>> {
187 crate::local_log_store::repair_log(path, retention);
188 let file = match File::open(path) {
189 Ok(file) => file,
190 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
191 Err(error) => return Err(error),
192 };
193 let reader = BufReader::new(file);
194 let mut entries = reader
195 .lines()
196 .map_while(Result::ok)
197 .filter_map(|line| serde_json::from_str::<CodexRelayEvidenceEntry>(&line).ok())
198 .filter(|entry| filters.matches(entry))
199 .collect::<Vec<_>>();
200 entries.reverse();
201 entries.truncate(limit);
202 Ok(entries)
203}
204
205fn evidence_lock() -> &'static Mutex<()> {
206 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
207 LOCK.get_or_init(|| Mutex::new(()))
208}
209
210fn codex_relay_evidence_retention() -> LogRetention {
211 static RETENTION: OnceLock<LogRetention> = OnceLock::new();
212 *RETENTION.get_or_init(|| {
213 LogRetention::from_env(
214 "CODEX_HELPER_RELAY_EVIDENCE_LOG_MAX_BYTES",
215 "CODEX_HELPER_RELAY_EVIDENCE_LOG_MAX_FILES",
216 DEFAULT_CODEX_RELAY_EVIDENCE_MAX_BYTES,
217 DEFAULT_CODEX_RELAY_EVIDENCE_MAX_FILES,
218 )
219 })
220}
221
222fn normalize_source(source: &str) -> String {
223 let trimmed = source.trim();
224 if trimmed.is_empty() {
225 "proxy_service".to_string()
226 } else {
227 trimmed.to_string()
228 }
229}
230
231fn contains_ignore_ascii_case(value: &str, needle: &str) -> bool {
232 value
233 .to_ascii_lowercase()
234 .contains(needle.to_ascii_lowercase().as_str())
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::codex_capability_profile::{
241 CodexCapabilityProfile, CodexCapabilityProfileInput, CodexCapabilitySupport,
242 CodexModelCatalogProfile, CodexPatchModeRecommendation, CodexPatchModeRecommendationInput,
243 };
244 use crate::codex_integration::CodexPatchMode;
245 use crate::proxy::{
246 CodexRelayCapabilitiesObserved, CodexRelayContinuityDiagnostics,
247 CodexRelayContinuityDomainSummary, CodexRelayLiveSmokeCase, CodexRelayLiveSmokeConfidence,
248 CodexRelayLiveSmokeOutcome, CodexRelayLiveSmokeResult, CodexRelayLiveSmokeSideEffect,
249 CodexRelayProbeConfidence, CodexRelayProbeKind, CodexRelayProbeResult,
250 CodexRelayProbeSupport,
251 };
252
253 fn temp_evidence_path() -> PathBuf {
254 let mut path = std::env::temp_dir();
255 path.push(format!(
256 "codex-helper-evidence-test-{}.jsonl",
257 Uuid::new_v4()
258 ));
259 path
260 }
261
262 fn probe_result(
263 kind: CodexRelayProbeKind,
264 support: CodexRelayProbeSupport,
265 ) -> CodexRelayProbeResult {
266 CodexRelayProbeResult {
267 kind,
268 support,
269 confidence: CodexRelayProbeConfidence::SuccessStatus,
270 status_code: Some(200),
271 response_shape: Some("ok".to_string()),
272 translation_required: false,
273 error_class: None,
274 reason: "ok".to_string(),
275 }
276 }
277
278 fn capabilities_response() -> CodexRelayCapabilitiesResponse {
279 let model_catalog = CodexModelCatalogProfile::unknown("test");
280 let expected =
281 CodexCapabilityProfile::for_input(CodexCapabilityProfileInput::from_patch_mode(
282 CodexPatchMode::OfficialImagegenBridge,
283 model_catalog.clone(),
284 ));
285 let recommendation =
286 CodexPatchModeRecommendation::for_input(CodexPatchModeRecommendationInput {
287 current_patch_mode: CodexPatchMode::OfficialImagegenBridge,
288 model_catalog,
289 responses: CodexCapabilitySupport::Supported,
290 responses_compact: CodexCapabilitySupport::Supported,
291 });
292 CodexRelayCapabilitiesResponse {
293 api_version: 1,
294 service_name: "codex".to_string(),
295 station_name: "input".to_string(),
296 upstream_index: 0,
297 provider_id: None,
298 endpoint_id: None,
299 provider_endpoint_key: None,
300 upstream_base_url: "https://relay.example/v1".to_string(),
301 patch_mode: CodexPatchMode::OfficialImagegenBridge,
302 compaction: crate::codex_integration::CodexCompactionStrategy::Auto,
303 responses_websocket: false,
304 model: Some("gpt-5.5".to_string()),
305 expected,
306 observed: CodexRelayCapabilitiesObserved {
307 models: probe_result(
308 CodexRelayProbeKind::Models,
309 CodexRelayProbeSupport::Supported,
310 ),
311 responses: probe_result(
312 CodexRelayProbeKind::Responses,
313 CodexRelayProbeSupport::Supported,
314 ),
315 responses_compact: probe_result(
316 CodexRelayProbeKind::ResponsesCompact,
317 CodexRelayProbeSupport::Supported,
318 ),
319 },
320 recommendation,
321 continuity: CodexRelayContinuityDiagnostics {
322 selected_domain: CodexRelayContinuityDomainSummary {
323 key: "provider_endpoint:codex/input/default".to_string(),
324 explicit: false,
325 },
326 same_domain_endpoint_count: 1,
327 configured_endpoint_count: 1,
328 affinity_policy: Some("fallback-sticky".to_string()),
329 can_state_bound_failover_within_domain: false,
330 warnings: Vec::new(),
331 recommendations: Vec::new(),
332 },
333 mismatches: Vec::new(),
334 }
335 }
336
337 fn live_smoke_response(model: &str) -> CodexRelayLiveSmokeResponse {
338 CodexRelayLiveSmokeResponse {
339 api_version: 1,
340 service_name: "codex".to_string(),
341 station_name: "input".to_string(),
342 upstream_index: 0,
343 provider_id: None,
344 endpoint_id: None,
345 provider_endpoint_key: None,
346 upstream_base_url: "https://relay.example/v1".to_string(),
347 requested_model: model.to_string(),
348 upstream_model: model.to_string(),
349 cases: vec![CodexRelayLiveSmokeCase::ResponsesCompact],
350 results: vec![CodexRelayLiveSmokeResult {
351 case: CodexRelayLiveSmokeCase::ResponsesCompact,
352 outcome: CodexRelayLiveSmokeOutcome::Passed,
353 confidence: CodexRelayLiveSmokeConfidence::LiveOutputShape,
354 side_effect: CodexRelayLiveSmokeSideEffect::LiveRequest,
355 status_code: Some(200),
356 response_shape: Some("compact_output".to_string()),
357 output_items_seen: 1,
358 compaction_output_seen: true,
359 compaction_output_items_seen: 1,
360 response_completed_seen: false,
361 image_generation_call_seen: false,
362 image_result_present: false,
363 accepted_by_responses: true,
364 error_class: None,
365 reason: "ok".to_string(),
366 }],
367 warnings: Vec::new(),
368 }
369 }
370
371 #[test]
372 fn codex_relay_evidence_appends_and_reads_newest_first() {
373 let path = temp_evidence_path();
374 let capability = capability_evidence_entry(&capabilities_response(), "unit").unwrap();
375 append_codex_relay_evidence_entry(&path, &capability).unwrap();
376 let live = live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap();
377 append_codex_relay_evidence_entry(&path, &live).unwrap();
378
379 let entries =
380 read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
381
382 assert_eq!(entries.len(), 2);
383 assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
384 assert_eq!(
385 entries[1].kind,
386 CodexRelayEvidenceKind::CapabilityDiagnostics
387 );
388 assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
389 assert!(entries[0].payload.get("results").is_some());
390 }
391
392 #[test]
393 fn codex_relay_evidence_filters_by_kind_station_and_model() {
394 let path = temp_evidence_path();
395 append_codex_relay_evidence_entry(
396 &path,
397 &live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap(),
398 )
399 .unwrap();
400 append_codex_relay_evidence_entry(
401 &path,
402 &live_smoke_evidence_entry(&live_smoke_response("o3"), "unit").unwrap(),
403 )
404 .unwrap();
405 append_codex_relay_evidence_entry(
406 &path,
407 &capability_evidence_entry(&capabilities_response(), "unit").unwrap(),
408 )
409 .unwrap();
410
411 let entries = read_recent_codex_relay_evidence_from_path(
412 &path,
413 10,
414 &CodexRelayEvidenceFilters {
415 kind: Some(CodexRelayEvidenceKind::LiveSmoke),
416 station_name: Some("inp".to_string()),
417 model: Some("gpt".to_string()),
418 },
419 )
420 .unwrap();
421
422 assert_eq!(entries.len(), 1);
423 assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
424 assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
425 }
426
427 #[test]
428 fn codex_relay_evidence_missing_file_returns_empty() {
429 let path = temp_evidence_path();
430 let entries =
431 read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
432 assert!(entries.is_empty());
433 }
434
435 #[test]
436 fn codex_relay_evidence_repairs_oversized_active_log_before_reading() {
437 let path = temp_evidence_path();
438 std::fs::write(&path, vec![b'x'; 32]).expect("seed oversized evidence log");
439
440 let entries = read_recent_codex_relay_evidence_from_path_with_retention(
441 &path,
442 10,
443 &Default::default(),
444 LogRetention::new(16, 1),
445 )
446 .expect("read repaired evidence log");
447
448 assert!(entries.is_empty());
449 assert!(
450 !path.exists(),
451 "oversized active evidence log should be rotated away before reading"
452 );
453 assert!(
454 crate::local_log_store::collect_rotated_logs(&path).is_empty(),
455 "oversized rotated evidence log should be pruned by retention budget"
456 );
457 let _ = std::fs::remove_file(path);
458 }
459
460 #[test]
461 fn codex_relay_evidence_uses_bounded_jsonl_rotation() {
462 let path = temp_evidence_path();
463 std::fs::write(&path, vec![b'x'; 32]).expect("seed oversized evidence log");
464 let entry = capability_evidence_entry(&capabilities_response(), "unit").unwrap();
465
466 append_line(
467 &path,
468 LogRetention::new(16, 2),
469 &serde_json::to_string(&entry).unwrap(),
470 )
471 .expect("append evidence through bounded store");
472
473 assert!(
474 path.exists(),
475 "bounded append should recreate active evidence log"
476 );
477 let rotated = crate::local_log_store::collect_rotated_logs(&path);
478 assert_eq!(rotated.len(), 1);
479 let rotated_name = rotated[0]
480 .path
481 .file_name()
482 .and_then(|name| name.to_str())
483 .expect("rotated evidence name");
484 let expected_prefix = format!(
485 "{}.",
486 path.file_stem()
487 .and_then(|name| name.to_str())
488 .expect("active evidence stem")
489 );
490 assert!(rotated_name.starts_with(&expected_prefix));
491 assert!(rotated_name.ends_with(".jsonl"));
492 let _ = std::fs::remove_file(path);
493 for file in rotated {
494 let _ = std::fs::remove_file(file.path);
495 }
496 }
497}