Skip to main content

codex_helper_core/proxy/
codex_relay_evidence.rs

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}