use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::config::proxy_home_dir;
use crate::logging::now_ms;
use super::{CodexRelayCapabilitiesResponse, CodexRelayLiveSmokeResponse};
const CODEX_RELAY_EVIDENCE_SCHEMA_VERSION: u32 = 1;
const CODEX_RELAY_EVIDENCE_FILE: &str = "codex_relay_evidence.jsonl";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CodexRelayEvidenceKind {
CapabilityDiagnostics,
LiveSmoke,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CodexRelayEvidenceEntry {
pub schema_version: u32,
pub evidence_id: String,
pub timestamp_ms: u64,
pub source: String,
pub kind: CodexRelayEvidenceKind,
pub service_name: String,
pub station_name: String,
pub upstream_index: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_endpoint_key: Option<String>,
pub upstream_base_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub payload: Value,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodexRelayEvidenceFilters {
pub kind: Option<CodexRelayEvidenceKind>,
pub station_name: Option<String>,
pub model: Option<String>,
}
impl CodexRelayEvidenceFilters {
pub fn matches(&self, entry: &CodexRelayEvidenceEntry) -> bool {
if let Some(kind) = self.kind
&& entry.kind != kind
{
return false;
}
if let Some(station_name) = self.station_name.as_deref()
&& !contains_ignore_ascii_case(entry.station_name.as_str(), station_name)
{
return false;
}
if let Some(model) = self.model.as_deref()
&& !entry
.model
.as_deref()
.is_some_and(|value| contains_ignore_ascii_case(value, model))
{
return false;
}
true
}
}
pub fn codex_relay_evidence_path() -> PathBuf {
proxy_home_dir()
.join("logs")
.join(CODEX_RELAY_EVIDENCE_FILE)
}
pub fn append_codex_relay_capabilities_evidence(
response: &CodexRelayCapabilitiesResponse,
source: &str,
) -> std::io::Result<CodexRelayEvidenceEntry> {
let entry = capability_evidence_entry(response, source)?;
append_codex_relay_evidence_entry(&codex_relay_evidence_path(), &entry)?;
Ok(entry)
}
pub fn append_codex_relay_live_smoke_evidence(
response: &CodexRelayLiveSmokeResponse,
source: &str,
) -> std::io::Result<CodexRelayEvidenceEntry> {
let entry = live_smoke_evidence_entry(response, source)?;
append_codex_relay_evidence_entry(&codex_relay_evidence_path(), &entry)?;
Ok(entry)
}
pub fn read_recent_codex_relay_evidence(
limit: usize,
filters: &CodexRelayEvidenceFilters,
) -> std::io::Result<Vec<CodexRelayEvidenceEntry>> {
read_recent_codex_relay_evidence_from_path(&codex_relay_evidence_path(), limit, filters)
}
fn capability_evidence_entry(
response: &CodexRelayCapabilitiesResponse,
source: &str,
) -> std::io::Result<CodexRelayEvidenceEntry> {
Ok(CodexRelayEvidenceEntry {
schema_version: CODEX_RELAY_EVIDENCE_SCHEMA_VERSION,
evidence_id: Uuid::new_v4().to_string(),
timestamp_ms: now_ms(),
source: normalize_source(source),
kind: CodexRelayEvidenceKind::CapabilityDiagnostics,
service_name: response.service_name.clone(),
station_name: response.station_name.clone(),
upstream_index: response.upstream_index,
provider_id: response.provider_id.clone(),
endpoint_id: response.endpoint_id.clone(),
provider_endpoint_key: response.provider_endpoint_key.clone(),
upstream_base_url: response.upstream_base_url.clone(),
model: response.model.clone(),
payload: serde_json::to_value(response).map_err(std::io::Error::other)?,
})
}
fn live_smoke_evidence_entry(
response: &CodexRelayLiveSmokeResponse,
source: &str,
) -> std::io::Result<CodexRelayEvidenceEntry> {
Ok(CodexRelayEvidenceEntry {
schema_version: CODEX_RELAY_EVIDENCE_SCHEMA_VERSION,
evidence_id: Uuid::new_v4().to_string(),
timestamp_ms: now_ms(),
source: normalize_source(source),
kind: CodexRelayEvidenceKind::LiveSmoke,
service_name: response.service_name.clone(),
station_name: response.station_name.clone(),
upstream_index: response.upstream_index,
provider_id: response.provider_id.clone(),
endpoint_id: response.endpoint_id.clone(),
provider_endpoint_key: response.provider_endpoint_key.clone(),
upstream_base_url: response.upstream_base_url.clone(),
model: Some(response.requested_model.clone()),
payload: serde_json::to_value(response).map_err(std::io::Error::other)?,
})
}
fn append_codex_relay_evidence_entry(
path: &Path,
entry: &CodexRelayEvidenceEntry,
) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let _guard = match evidence_lock().lock() {
Ok(guard) => guard,
Err(error) => error.into_inner(),
};
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
writeln!(file, "{line}")?;
Ok(())
}
fn read_recent_codex_relay_evidence_from_path(
path: &Path,
limit: usize,
filters: &CodexRelayEvidenceFilters,
) -> std::io::Result<Vec<CodexRelayEvidenceEntry>> {
let file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error),
};
let reader = BufReader::new(file);
let mut entries = reader
.lines()
.map_while(Result::ok)
.filter_map(|line| serde_json::from_str::<CodexRelayEvidenceEntry>(&line).ok())
.filter(|entry| filters.matches(entry))
.collect::<Vec<_>>();
entries.reverse();
entries.truncate(limit);
Ok(entries)
}
fn evidence_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn normalize_source(source: &str) -> String {
let trimmed = source.trim();
if trimmed.is_empty() {
"proxy_service".to_string()
} else {
trimmed.to_string()
}
}
fn contains_ignore_ascii_case(value: &str, needle: &str) -> bool {
value
.to_ascii_lowercase()
.contains(needle.to_ascii_lowercase().as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codex_capability_profile::{
CodexCapabilityProfile, CodexCapabilityProfileInput, CodexCapabilitySupport,
CodexModelCatalogProfile, CodexPatchModeRecommendation, CodexPatchModeRecommendationInput,
};
use crate::codex_integration::CodexPatchMode;
use crate::proxy::{
CodexRelayCapabilitiesObserved, CodexRelayContinuityDiagnostics,
CodexRelayContinuityDomainSummary, CodexRelayLiveSmokeCase, CodexRelayLiveSmokeConfidence,
CodexRelayLiveSmokeOutcome, CodexRelayLiveSmokeResult, CodexRelayLiveSmokeSideEffect,
CodexRelayProbeConfidence, CodexRelayProbeKind, CodexRelayProbeResult,
CodexRelayProbeSupport,
};
fn temp_evidence_path() -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"codex-helper-evidence-test-{}.jsonl",
Uuid::new_v4()
));
path
}
fn probe_result(
kind: CodexRelayProbeKind,
support: CodexRelayProbeSupport,
) -> CodexRelayProbeResult {
CodexRelayProbeResult {
kind,
support,
confidence: CodexRelayProbeConfidence::SuccessStatus,
status_code: Some(200),
response_shape: Some("ok".to_string()),
translation_required: false,
error_class: None,
reason: "ok".to_string(),
}
}
fn capabilities_response() -> CodexRelayCapabilitiesResponse {
let model_catalog = CodexModelCatalogProfile::unknown("test");
let expected =
CodexCapabilityProfile::for_input(CodexCapabilityProfileInput::from_patch_mode(
CodexPatchMode::OfficialImagegenBridge,
model_catalog.clone(),
));
let recommendation =
CodexPatchModeRecommendation::for_input(CodexPatchModeRecommendationInput {
current_patch_mode: CodexPatchMode::OfficialImagegenBridge,
model_catalog,
responses: CodexCapabilitySupport::Supported,
responses_compact: CodexCapabilitySupport::Supported,
});
CodexRelayCapabilitiesResponse {
api_version: 1,
service_name: "codex".to_string(),
station_name: "input".to_string(),
upstream_index: 0,
provider_id: None,
endpoint_id: None,
provider_endpoint_key: None,
upstream_base_url: "https://relay.example/v1".to_string(),
patch_mode: CodexPatchMode::OfficialImagegenBridge,
responses_websocket: false,
model: Some("gpt-5.5".to_string()),
expected,
observed: CodexRelayCapabilitiesObserved {
models: probe_result(
CodexRelayProbeKind::Models,
CodexRelayProbeSupport::Supported,
),
responses: probe_result(
CodexRelayProbeKind::Responses,
CodexRelayProbeSupport::Supported,
),
responses_compact: probe_result(
CodexRelayProbeKind::ResponsesCompact,
CodexRelayProbeSupport::Supported,
),
},
recommendation,
continuity: CodexRelayContinuityDiagnostics {
selected_domain: CodexRelayContinuityDomainSummary {
key: "provider_endpoint:codex/input/default".to_string(),
explicit: false,
},
same_domain_endpoint_count: 1,
configured_endpoint_count: 1,
affinity_policy: Some("fallback-sticky".to_string()),
can_state_bound_failover_within_domain: false,
warnings: Vec::new(),
recommendations: Vec::new(),
},
mismatches: Vec::new(),
}
}
fn live_smoke_response(model: &str) -> CodexRelayLiveSmokeResponse {
CodexRelayLiveSmokeResponse {
api_version: 1,
service_name: "codex".to_string(),
station_name: "input".to_string(),
upstream_index: 0,
provider_id: None,
endpoint_id: None,
provider_endpoint_key: None,
upstream_base_url: "https://relay.example/v1".to_string(),
requested_model: model.to_string(),
upstream_model: model.to_string(),
cases: vec![CodexRelayLiveSmokeCase::ResponsesCompact],
results: vec![CodexRelayLiveSmokeResult {
case: CodexRelayLiveSmokeCase::ResponsesCompact,
outcome: CodexRelayLiveSmokeOutcome::Passed,
confidence: CodexRelayLiveSmokeConfidence::LiveOutputShape,
side_effect: CodexRelayLiveSmokeSideEffect::LiveRequest,
status_code: Some(200),
response_shape: Some("compact_output".to_string()),
output_items_seen: 1,
compaction_output_seen: true,
compaction_output_items_seen: 1,
response_completed_seen: false,
image_generation_call_seen: false,
image_result_present: false,
accepted_by_responses: true,
error_class: None,
reason: "ok".to_string(),
}],
warnings: Vec::new(),
}
}
#[test]
fn codex_relay_evidence_appends_and_reads_newest_first() {
let path = temp_evidence_path();
let capability = capability_evidence_entry(&capabilities_response(), "unit").unwrap();
append_codex_relay_evidence_entry(&path, &capability).unwrap();
let live = live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap();
append_codex_relay_evidence_entry(&path, &live).unwrap();
let entries =
read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
assert_eq!(
entries[1].kind,
CodexRelayEvidenceKind::CapabilityDiagnostics
);
assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
assert!(entries[0].payload.get("results").is_some());
}
#[test]
fn codex_relay_evidence_filters_by_kind_station_and_model() {
let path = temp_evidence_path();
append_codex_relay_evidence_entry(
&path,
&live_smoke_evidence_entry(&live_smoke_response("gpt-5.5"), "unit").unwrap(),
)
.unwrap();
append_codex_relay_evidence_entry(
&path,
&live_smoke_evidence_entry(&live_smoke_response("o3"), "unit").unwrap(),
)
.unwrap();
append_codex_relay_evidence_entry(
&path,
&capability_evidence_entry(&capabilities_response(), "unit").unwrap(),
)
.unwrap();
let entries = read_recent_codex_relay_evidence_from_path(
&path,
10,
&CodexRelayEvidenceFilters {
kind: Some(CodexRelayEvidenceKind::LiveSmoke),
station_name: Some("inp".to_string()),
model: Some("gpt".to_string()),
},
)
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind, CodexRelayEvidenceKind::LiveSmoke);
assert_eq!(entries[0].model.as_deref(), Some("gpt-5.5"));
}
#[test]
fn codex_relay_evidence_missing_file_returns_empty() {
let path = temp_evidence_path();
let entries =
read_recent_codex_relay_evidence_from_path(&path, 10, &Default::default()).unwrap();
assert!(entries.is_empty());
}
}