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