1use std::fs::{self, File, OpenOptions};
2use std::io::{BufRead, BufReader, Write};
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::logging::now_ms;
12
13use super::{CodexRelayCapabilitiesResponse, CodexRelayLiveSmokeResponse};
14
15const CODEX_RELAY_EVIDENCE_SCHEMA_VERSION: u32 = 1;
16const CODEX_RELAY_EVIDENCE_FILE: &str = "codex_relay_evidence.jsonl";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum CodexRelayEvidenceKind {
21 CapabilityDiagnostics,
22 LiveSmoke,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct CodexRelayEvidenceEntry {
27 pub schema_version: u32,
28 pub evidence_id: String,
29 pub timestamp_ms: u64,
30 pub source: String,
31 pub kind: CodexRelayEvidenceKind,
32 pub service_name: String,
33 pub station_name: String,
34 pub upstream_index: usize,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub provider_id: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub endpoint_id: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub provider_endpoint_key: Option<String>,
41 pub upstream_base_url: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub model: Option<String>,
44 pub payload: Value,
45}
46
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct CodexRelayEvidenceFilters {
49 pub kind: Option<CodexRelayEvidenceKind>,
50 pub station_name: Option<String>,
51 pub model: Option<String>,
52}
53
54impl CodexRelayEvidenceFilters {
55 pub fn matches(&self, entry: &CodexRelayEvidenceEntry) -> bool {
56 if let Some(kind) = self.kind
57 && entry.kind != kind
58 {
59 return false;
60 }
61 if let Some(station_name) = self.station_name.as_deref()
62 && !contains_ignore_ascii_case(entry.station_name.as_str(), station_name)
63 {
64 return false;
65 }
66 if let Some(model) = self.model.as_deref()
67 && !entry
68 .model
69 .as_deref()
70 .is_some_and(|value| contains_ignore_ascii_case(value, model))
71 {
72 return false;
73 }
74 true
75 }
76}
77
78pub fn codex_relay_evidence_path() -> PathBuf {
79 proxy_home_dir()
80 .join("logs")
81 .join(CODEX_RELAY_EVIDENCE_FILE)
82}
83
84pub fn append_codex_relay_capabilities_evidence(
85 response: &CodexRelayCapabilitiesResponse,
86 source: &str,
87) -> std::io::Result<CodexRelayEvidenceEntry> {
88 let entry = capability_evidence_entry(response, source)?;
89 append_codex_relay_evidence_entry(&codex_relay_evidence_path(), &entry)?;
90 Ok(entry)
91}
92
93pub fn append_codex_relay_live_smoke_evidence(
94 response: &CodexRelayLiveSmokeResponse,
95 source: &str,
96) -> std::io::Result<CodexRelayEvidenceEntry> {
97 let entry = live_smoke_evidence_entry(response, source)?;
98 append_codex_relay_evidence_entry(&codex_relay_evidence_path(), &entry)?;
99 Ok(entry)
100}
101
102pub fn read_recent_codex_relay_evidence(
103 limit: usize,
104 filters: &CodexRelayEvidenceFilters,
105) -> std::io::Result<Vec<CodexRelayEvidenceEntry>> {
106 read_recent_codex_relay_evidence_from_path(&codex_relay_evidence_path(), limit, filters)
107}
108
109fn capability_evidence_entry(
110 response: &CodexRelayCapabilitiesResponse,
111 source: &str,
112) -> std::io::Result<CodexRelayEvidenceEntry> {
113 Ok(CodexRelayEvidenceEntry {
114 schema_version: CODEX_RELAY_EVIDENCE_SCHEMA_VERSION,
115 evidence_id: Uuid::new_v4().to_string(),
116 timestamp_ms: now_ms(),
117 source: normalize_source(source),
118 kind: CodexRelayEvidenceKind::CapabilityDiagnostics,
119 service_name: response.service_name.clone(),
120 station_name: response.station_name.clone(),
121 upstream_index: response.upstream_index,
122 provider_id: response.provider_id.clone(),
123 endpoint_id: response.endpoint_id.clone(),
124 provider_endpoint_key: response.provider_endpoint_key.clone(),
125 upstream_base_url: response.upstream_base_url.clone(),
126 model: response.model.clone(),
127 payload: serde_json::to_value(response).map_err(std::io::Error::other)?,
128 })
129}
130
131fn live_smoke_evidence_entry(
132 response: &CodexRelayLiveSmokeResponse,
133 source: &str,
134) -> std::io::Result<CodexRelayEvidenceEntry> {
135 Ok(CodexRelayEvidenceEntry {
136 schema_version: CODEX_RELAY_EVIDENCE_SCHEMA_VERSION,
137 evidence_id: Uuid::new_v4().to_string(),
138 timestamp_ms: now_ms(),
139 source: normalize_source(source),
140 kind: CodexRelayEvidenceKind::LiveSmoke,
141 service_name: response.service_name.clone(),
142 station_name: response.station_name.clone(),
143 upstream_index: response.upstream_index,
144 provider_id: response.provider_id.clone(),
145 endpoint_id: response.endpoint_id.clone(),
146 provider_endpoint_key: response.provider_endpoint_key.clone(),
147 upstream_base_url: response.upstream_base_url.clone(),
148 model: Some(response.requested_model.clone()),
149 payload: serde_json::to_value(response).map_err(std::io::Error::other)?,
150 })
151}
152
153fn append_codex_relay_evidence_entry(
154 path: &Path,
155 entry: &CodexRelayEvidenceEntry,
156) -> std::io::Result<()> {
157 if let Some(parent) = path.parent() {
158 fs::create_dir_all(parent)?;
159 }
160 let _guard = match evidence_lock().lock() {
161 Ok(guard) => guard,
162 Err(error) => error.into_inner(),
163 };
164 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
165 let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
166 writeln!(file, "{line}")?;
167 Ok(())
168}
169
170fn read_recent_codex_relay_evidence_from_path(
171 path: &Path,
172 limit: usize,
173 filters: &CodexRelayEvidenceFilters,
174) -> std::io::Result<Vec<CodexRelayEvidenceEntry>> {
175 let file = match File::open(path) {
176 Ok(file) => file,
177 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
178 Err(error) => return Err(error),
179 };
180 let reader = BufReader::new(file);
181 let mut entries = reader
182 .lines()
183 .map_while(Result::ok)
184 .filter_map(|line| serde_json::from_str::<CodexRelayEvidenceEntry>(&line).ok())
185 .filter(|entry| filters.matches(entry))
186 .collect::<Vec<_>>();
187 entries.reverse();
188 entries.truncate(limit);
189 Ok(entries)
190}
191
192fn evidence_lock() -> &'static Mutex<()> {
193 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
194 LOCK.get_or_init(|| Mutex::new(()))
195}
196
197fn normalize_source(source: &str) -> String {
198 let trimmed = source.trim();
199 if trimmed.is_empty() {
200 "proxy_service".to_string()
201 } else {
202 trimmed.to_string()
203 }
204}
205
206fn contains_ignore_ascii_case(value: &str, needle: &str) -> bool {
207 value
208 .to_ascii_lowercase()
209 .contains(needle.to_ascii_lowercase().as_str())
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::codex_capability_profile::{
216 CodexCapabilityProfile, CodexCapabilityProfileInput, CodexCapabilitySupport,
217 CodexModelCatalogProfile, CodexPatchModeRecommendation, CodexPatchModeRecommendationInput,
218 };
219 use crate::codex_integration::CodexPatchMode;
220 use crate::proxy::{
221 CodexRelayCapabilitiesObserved, CodexRelayLiveSmokeCase, CodexRelayLiveSmokeConfidence,
222 CodexRelayLiveSmokeOutcome, CodexRelayLiveSmokeResult, CodexRelayLiveSmokeSideEffect,
223 CodexRelayProbeConfidence, CodexRelayProbeKind, CodexRelayProbeResult,
224 CodexRelayProbeSupport,
225 };
226
227 fn temp_evidence_path() -> PathBuf {
228 let mut path = std::env::temp_dir();
229 path.push(format!(
230 "codex-helper-evidence-test-{}.jsonl",
231 Uuid::new_v4()
232 ));
233 path
234 }
235
236 fn probe_result(
237 kind: CodexRelayProbeKind,
238 support: CodexRelayProbeSupport,
239 ) -> CodexRelayProbeResult {
240 CodexRelayProbeResult {
241 kind,
242 support,
243 confidence: CodexRelayProbeConfidence::SuccessStatus,
244 status_code: Some(200),
245 response_shape: Some("ok".to_string()),
246 translation_required: false,
247 error_class: None,
248 reason: "ok".to_string(),
249 }
250 }
251
252 fn capabilities_response() -> CodexRelayCapabilitiesResponse {
253 let model_catalog = CodexModelCatalogProfile::unknown("test");
254 let expected =
255 CodexCapabilityProfile::for_input(CodexCapabilityProfileInput::from_patch_mode(
256 CodexPatchMode::OfficialImagegenBridge,
257 model_catalog.clone(),
258 ));
259 let recommendation =
260 CodexPatchModeRecommendation::for_input(CodexPatchModeRecommendationInput {
261 current_patch_mode: CodexPatchMode::OfficialImagegenBridge,
262 model_catalog,
263 responses: CodexCapabilitySupport::Supported,
264 responses_compact: CodexCapabilitySupport::Supported,
265 });
266 CodexRelayCapabilitiesResponse {
267 api_version: 1,
268 service_name: "codex".to_string(),
269 station_name: "input".to_string(),
270 upstream_index: 0,
271 provider_id: None,
272 endpoint_id: None,
273 provider_endpoint_key: None,
274 upstream_base_url: "https://relay.example/v1".to_string(),
275 patch_mode: CodexPatchMode::OfficialImagegenBridge,
276 responses_websocket: false,
277 model: Some("gpt-5.5".to_string()),
278 expected,
279 observed: CodexRelayCapabilitiesObserved {
280 models: probe_result(
281 CodexRelayProbeKind::Models,
282 CodexRelayProbeSupport::Supported,
283 ),
284 responses: probe_result(
285 CodexRelayProbeKind::Responses,
286 CodexRelayProbeSupport::Supported,
287 ),
288 responses_compact: probe_result(
289 CodexRelayProbeKind::ResponsesCompact,
290 CodexRelayProbeSupport::Supported,
291 ),
292 },
293 recommendation,
294 mismatches: Vec::new(),
295 }
296 }
297
298 fn live_smoke_response(model: &str) -> CodexRelayLiveSmokeResponse {
299 CodexRelayLiveSmokeResponse {
300 api_version: 1,
301 service_name: "codex".to_string(),
302 station_name: "input".to_string(),
303 upstream_index: 0,
304 provider_id: None,
305 endpoint_id: None,
306 provider_endpoint_key: None,
307 upstream_base_url: "https://relay.example/v1".to_string(),
308 requested_model: model.to_string(),
309 upstream_model: model.to_string(),
310 cases: vec![CodexRelayLiveSmokeCase::ResponsesCompact],
311 results: vec![CodexRelayLiveSmokeResult {
312 case: CodexRelayLiveSmokeCase::ResponsesCompact,
313 outcome: CodexRelayLiveSmokeOutcome::Passed,
314 confidence: CodexRelayLiveSmokeConfidence::LiveOutputShape,
315 side_effect: CodexRelayLiveSmokeSideEffect::LiveRequest,
316 status_code: Some(200),
317 response_shape: Some("compact_output".to_string()),
318 output_items_seen: 1,
319 image_generation_call_seen: false,
320 image_result_present: false,
321 accepted_by_responses: true,
322 error_class: None,
323 reason: "ok".to_string(),
324 }],
325 warnings: Vec::new(),
326 }
327 }
328
329 #[test]
330 fn codex_relay_evidence_appends_and_reads_newest_first() {
331 let path = temp_evidence_path();
332 let capability = capability_evidence_entry(&capabilities_response(), "unit").unwrap();
333 append_codex_relay_evidence_entry(&path, &capability).unwrap();
334 let live = live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap();
335 append_codex_relay_evidence_entry(&path, &live).unwrap();
336
337 let entries =
338 read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
339
340 assert_eq!(entries.len(), 2);
341 assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
342 assert_eq!(
343 entries[1].kind,
344 CodexRelayEvidenceKind::CapabilityDiagnostics
345 );
346 assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
347 assert!(entries[0].payload.get("results").is_some());
348 }
349
350 #[test]
351 fn codex_relay_evidence_filters_by_kind_station_and_model() {
352 let path = temp_evidence_path();
353 append_codex_relay_evidence_entry(
354 &path,
355 &live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap(),
356 )
357 .unwrap();
358 append_codex_relay_evidence_entry(
359 &path,
360 &live_smoke_evidence_entry(&live_smoke_response("o3"), "unit").unwrap(),
361 )
362 .unwrap();
363 append_codex_relay_evidence_entry(
364 &path,
365 &capability_evidence_entry(&capabilities_response(), "unit").unwrap(),
366 )
367 .unwrap();
368
369 let entries = read_recent_codex_relay_evidence_from_path(
370 &path,
371 10,
372 &CodexRelayEvidenceFilters {
373 kind: Some(CodexRelayEvidenceKind::LiveSmoke),
374 station_name: Some("inp".to_string()),
375 model: Some("gpt".to_string()),
376 },
377 )
378 .unwrap();
379
380 assert_eq!(entries.len(), 1);
381 assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
382 assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
383 }
384
385 #[test]
386 fn codex_relay_evidence_missing_file_returns_empty() {
387 let path = temp_evidence_path();
388 let entries =
389 read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
390 assert!(entries.is_empty());
391 }
392}