Skip to main content

codex_helper_core/proxy/
codex_relay_evidence.rs

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}