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, CodexRelayContinuityDiagnostics,
222 CodexRelayContinuityDomainSummary, CodexRelayLiveSmokeCase, CodexRelayLiveSmokeConfidence,
223 CodexRelayLiveSmokeOutcome, CodexRelayLiveSmokeResult, CodexRelayLiveSmokeSideEffect,
224 CodexRelayProbeConfidence, CodexRelayProbeKind, CodexRelayProbeResult,
225 CodexRelayProbeSupport,
226 };
227
228 fn temp_evidence_path() -> PathBuf {
229 let mut path = std::env::temp_dir();
230 path.push(format!(
231 "codex-helper-evidence-test-{}.jsonl",
232 Uuid::new_v4()
233 ));
234 path
235 }
236
237 fn probe_result(
238 kind: CodexRelayProbeKind,
239 support: CodexRelayProbeSupport,
240 ) -> CodexRelayProbeResult {
241 CodexRelayProbeResult {
242 kind,
243 support,
244 confidence: CodexRelayProbeConfidence::SuccessStatus,
245 status_code: Some(200),
246 response_shape: Some("ok".to_string()),
247 translation_required: false,
248 error_class: None,
249 reason: "ok".to_string(),
250 }
251 }
252
253 fn capabilities_response() -> CodexRelayCapabilitiesResponse {
254 let model_catalog = CodexModelCatalogProfile::unknown("test");
255 let expected =
256 CodexCapabilityProfile::for_input(CodexCapabilityProfileInput::from_patch_mode(
257 CodexPatchMode::OfficialImagegenBridge,
258 model_catalog.clone(),
259 ));
260 let recommendation =
261 CodexPatchModeRecommendation::for_input(CodexPatchModeRecommendationInput {
262 current_patch_mode: CodexPatchMode::OfficialImagegenBridge,
263 model_catalog,
264 responses: CodexCapabilitySupport::Supported,
265 responses_compact: CodexCapabilitySupport::Supported,
266 });
267 CodexRelayCapabilitiesResponse {
268 api_version: 1,
269 service_name: "codex".to_string(),
270 station_name: "input".to_string(),
271 upstream_index: 0,
272 provider_id: None,
273 endpoint_id: None,
274 provider_endpoint_key: None,
275 upstream_base_url: "https://relay.example/v1".to_string(),
276 patch_mode: CodexPatchMode::OfficialImagegenBridge,
277 responses_websocket: false,
278 model: Some("gpt-5.5".to_string()),
279 expected,
280 observed: CodexRelayCapabilitiesObserved {
281 models: probe_result(
282 CodexRelayProbeKind::Models,
283 CodexRelayProbeSupport::Supported,
284 ),
285 responses: probe_result(
286 CodexRelayProbeKind::Responses,
287 CodexRelayProbeSupport::Supported,
288 ),
289 responses_compact: probe_result(
290 CodexRelayProbeKind::ResponsesCompact,
291 CodexRelayProbeSupport::Supported,
292 ),
293 },
294 recommendation,
295 continuity: CodexRelayContinuityDiagnostics {
296 selected_domain: CodexRelayContinuityDomainSummary {
297 key: "provider_endpoint:codex/input/default".to_string(),
298 explicit: false,
299 },
300 same_domain_endpoint_count: 1,
301 configured_endpoint_count: 1,
302 affinity_policy: Some("fallback-sticky".to_string()),
303 can_state_bound_failover_within_domain: false,
304 warnings: Vec::new(),
305 recommendations: Vec::new(),
306 },
307 mismatches: Vec::new(),
308 }
309 }
310
311 fn live_smoke_response(model: &str) -> CodexRelayLiveSmokeResponse {
312 CodexRelayLiveSmokeResponse {
313 api_version: 1,
314 service_name: "codex".to_string(),
315 station_name: "input".to_string(),
316 upstream_index: 0,
317 provider_id: None,
318 endpoint_id: None,
319 provider_endpoint_key: None,
320 upstream_base_url: "https://relay.example/v1".to_string(),
321 requested_model: model.to_string(),
322 upstream_model: model.to_string(),
323 cases: vec![CodexRelayLiveSmokeCase::ResponsesCompact],
324 results: vec![CodexRelayLiveSmokeResult {
325 case: CodexRelayLiveSmokeCase::ResponsesCompact,
326 outcome: CodexRelayLiveSmokeOutcome::Passed,
327 confidence: CodexRelayLiveSmokeConfidence::LiveOutputShape,
328 side_effect: CodexRelayLiveSmokeSideEffect::LiveRequest,
329 status_code: Some(200),
330 response_shape: Some("compact_output".to_string()),
331 output_items_seen: 1,
332 compaction_output_seen: true,
333 compaction_output_items_seen: 1,
334 response_completed_seen: false,
335 image_generation_call_seen: false,
336 image_result_present: false,
337 accepted_by_responses: true,
338 error_class: None,
339 reason: "ok".to_string(),
340 }],
341 warnings: Vec::new(),
342 }
343 }
344
345 #[test]
346 fn codex_relay_evidence_appends_and_reads_newest_first() {
347 let path = temp_evidence_path();
348 let capability = capability_evidence_entry(&capabilities_response(), "unit").unwrap();
349 append_codex_relay_evidence_entry(&path, &capability).unwrap();
350 let live = live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap();
351 append_codex_relay_evidence_entry(&path, &live).unwrap();
352
353 let entries =
354 read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
355
356 assert_eq!(entries.len(), 2);
357 assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
358 assert_eq!(
359 entries[1].kind,
360 CodexRelayEvidenceKind::CapabilityDiagnostics
361 );
362 assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
363 assert!(entries[0].payload.get("results").is_some());
364 }
365
366 #[test]
367 fn codex_relay_evidence_filters_by_kind_station_and_model() {
368 let path = temp_evidence_path();
369 append_codex_relay_evidence_entry(
370 &path,
371 &live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap(),
372 )
373 .unwrap();
374 append_codex_relay_evidence_entry(
375 &path,
376 &live_smoke_evidence_entry(&live_smoke_response("o3"), "unit").unwrap(),
377 )
378 .unwrap();
379 append_codex_relay_evidence_entry(
380 &path,
381 &capability_evidence_entry(&capabilities_response(), "unit").unwrap(),
382 )
383 .unwrap();
384
385 let entries = read_recent_codex_relay_evidence_from_path(
386 &path,
387 10,
388 &CodexRelayEvidenceFilters {
389 kind: Some(CodexRelayEvidenceKind::LiveSmoke),
390 station_name: Some("inp".to_string()),
391 model: Some("gpt".to_string()),
392 },
393 )
394 .unwrap();
395
396 assert_eq!(entries.len(), 1);
397 assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
398 assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
399 }
400
401 #[test]
402 fn codex_relay_evidence_missing_file_returns_empty() {
403 let path = temp_evidence_path();
404 let entries =
405 read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
406 assert!(entries.is_empty());
407 }
408}