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 responses_websocket: false,
303 model: Some("gpt-5.5".to_string()),
304 expected,
305 observed: CodexRelayCapabilitiesObserved {
306 models: probe_result(
307 CodexRelayProbeKind::Models,
308 CodexRelayProbeSupport::Supported,
309 ),
310 responses: probe_result(
311 CodexRelayProbeKind::Responses,
312 CodexRelayProbeSupport::Supported,
313 ),
314 responses_compact: probe_result(
315 CodexRelayProbeKind::ResponsesCompact,
316 CodexRelayProbeSupport::Supported,
317 ),
318 },
319 recommendation,
320 continuity: CodexRelayContinuityDiagnostics {
321 selected_domain: CodexRelayContinuityDomainSummary {
322 key: "provider_endpoint:codex/input/default".to_string(),
323 explicit: false,
324 },
325 same_domain_endpoint_count: 1,
326 configured_endpoint_count: 1,
327 affinity_policy: Some("fallback-sticky".to_string()),
328 can_state_bound_failover_within_domain: false,
329 warnings: Vec::new(),
330 recommendations: Vec::new(),
331 },
332 mismatches: Vec::new(),
333 }
334 }
335
336 fn live_smoke_response(model: &str) -> CodexRelayLiveSmokeResponse {
337 CodexRelayLiveSmokeResponse {
338 api_version: 1,
339 service_name: "codex".to_string(),
340 station_name: "input".to_string(),
341 upstream_index: 0,
342 provider_id: None,
343 endpoint_id: None,
344 provider_endpoint_key: None,
345 upstream_base_url: "https://relay.example/v1".to_string(),
346 requested_model: model.to_string(),
347 upstream_model: model.to_string(),
348 cases: vec![CodexRelayLiveSmokeCase::ResponsesCompact],
349 results: vec![CodexRelayLiveSmokeResult {
350 case: CodexRelayLiveSmokeCase::ResponsesCompact,
351 outcome: CodexRelayLiveSmokeOutcome::Passed,
352 confidence: CodexRelayLiveSmokeConfidence::LiveOutputShape,
353 side_effect: CodexRelayLiveSmokeSideEffect::LiveRequest,
354 status_code: Some(200),
355 response_shape: Some("compact_output".to_string()),
356 output_items_seen: 1,
357 compaction_output_seen: true,
358 compaction_output_items_seen: 1,
359 response_completed_seen: false,
360 image_generation_call_seen: false,
361 image_result_present: false,
362 accepted_by_responses: true,
363 error_class: None,
364 reason: "ok".to_string(),
365 }],
366 warnings: Vec::new(),
367 }
368 }
369
370 #[test]
371 fn codex_relay_evidence_appends_and_reads_newest_first() {
372 let path = temp_evidence_path();
373 let capability = capability_evidence_entry(&capabilities_response(), "unit").unwrap();
374 append_codex_relay_evidence_entry(&path, &capability).unwrap();
375 let live = live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap();
376 append_codex_relay_evidence_entry(&path, &live).unwrap();
377
378 let entries =
379 read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
380
381 assert_eq!(entries.len(), 2);
382 assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
383 assert_eq!(
384 entries[1].kind,
385 CodexRelayEvidenceKind::CapabilityDiagnostics
386 );
387 assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
388 assert!(entries[0].payload.get("results").is_some());
389 }
390
391 #[test]
392 fn codex_relay_evidence_filters_by_kind_station_and_model() {
393 let path = temp_evidence_path();
394 append_codex_relay_evidence_entry(
395 &path,
396 &live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap(),
397 )
398 .unwrap();
399 append_codex_relay_evidence_entry(
400 &path,
401 &live_smoke_evidence_entry(&live_smoke_response("o3"), "unit").unwrap(),
402 )
403 .unwrap();
404 append_codex_relay_evidence_entry(
405 &path,
406 &capability_evidence_entry(&capabilities_response(), "unit").unwrap(),
407 )
408 .unwrap();
409
410 let entries = read_recent_codex_relay_evidence_from_path(
411 &path,
412 10,
413 &CodexRelayEvidenceFilters {
414 kind: Some(CodexRelayEvidenceKind::LiveSmoke),
415 station_name: Some("inp".to_string()),
416 model: Some("gpt".to_string()),
417 },
418 )
419 .unwrap();
420
421 assert_eq!(entries.len(), 1);
422 assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
423 assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
424 }
425
426 #[test]
427 fn codex_relay_evidence_missing_file_returns_empty() {
428 let path = temp_evidence_path();
429 let entries =
430 read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
431 assert!(entries.is_empty());
432 }
433
434 #[test]
435 fn codex_relay_evidence_repairs_oversized_active_log_before_reading() {
436 let path = temp_evidence_path();
437 std::fs::write(&path, vec![b'x'; 32]).expect("seed oversized evidence log");
438
439 let entries = read_recent_codex_relay_evidence_from_path_with_retention(
440 &path,
441 10,
442 &Default::default(),
443 LogRetention::new(16, 1),
444 )
445 .expect("read repaired evidence log");
446
447 assert!(entries.is_empty());
448 assert!(
449 !path.exists(),
450 "oversized active evidence log should be rotated away before reading"
451 );
452 assert!(
453 crate::local_log_store::collect_rotated_logs(&path).is_empty(),
454 "oversized rotated evidence log should be pruned by retention budget"
455 );
456 let _ = std::fs::remove_file(path);
457 }
458
459 #[test]
460 fn codex_relay_evidence_uses_bounded_jsonl_rotation() {
461 let path = temp_evidence_path();
462 std::fs::write(&path, vec![b'x'; 32]).expect("seed oversized evidence log");
463 let entry = capability_evidence_entry(&capabilities_response(), "unit").unwrap();
464
465 append_line(
466 &path,
467 LogRetention::new(16, 2),
468 &serde_json::to_string(&entry).unwrap(),
469 )
470 .expect("append evidence through bounded store");
471
472 assert!(
473 path.exists(),
474 "bounded append should recreate active evidence log"
475 );
476 let rotated = crate::local_log_store::collect_rotated_logs(&path);
477 assert_eq!(rotated.len(), 1);
478 let rotated_name = rotated[0]
479 .path
480 .file_name()
481 .and_then(|name| name.to_str())
482 .expect("rotated evidence name");
483 let expected_prefix = format!(
484 "{}.",
485 path.file_stem()
486 .and_then(|name| name.to_str())
487 .expect("active evidence stem")
488 );
489 assert!(rotated_name.starts_with(&expected_prefix));
490 assert!(rotated_name.ends_with(".jsonl"));
491 let _ = std::fs::remove_file(path);
492 for file in rotated {
493 let _ = std::fs::remove_file(file.path);
494 }
495 }
496}