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            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}