codex-helper-core 0.15.0

Core library for codex-helper.
Documentation
use super::*;

#[test]
fn request_log_serializes_request_id_when_present() {
    let value = serde_json::to_value(RequestLog {
        timestamp_ms: 1,
        request_id: Some(42),
        trace_id: Some(request_trace_id("codex", 42)),
        service: "codex",
        method: "POST",
        path: "/v1/responses",
        status_code: 200,
        duration_ms: 123,
        ttfb_ms: Some(10),
        station_name: Some("right"),
        provider_id: Some("right".to_string()),
        endpoint_id: None,
        provider_endpoint_key: None,
        upstream_base_url: "https://example.com/v1",
        session_id: Some("sid-1".to_string()),
        cwd: Some("/workdir".to_string()),
        reasoning_effort: Some("medium".to_string()),
        service_tier: ServiceTierLog {
            requested: Some("priority".to_string()),
            effective: Some("priority".to_string()),
            actual: Some("priority".to_string()),
        },
        usage: None,
        http_debug: None,
        http_debug_ref: None,
        retry: None,
    })
    .expect("serialize request log");

    assert_eq!(value["request_id"].as_u64(), Some(42));
    assert_eq!(value["trace_id"].as_str(), Some("codex-42"));
}

#[test]
fn request_log_can_serialize_provider_endpoint_without_station_identity() {
    let value = serde_json::to_value(RequestLog {
        timestamp_ms: 1,
        request_id: Some(42),
        trace_id: Some(request_trace_id("codex", 42)),
        service: "codex",
        method: "POST",
        path: "/v1/responses",
        status_code: 200,
        duration_ms: 123,
        ttfb_ms: Some(10),
        station_name: None,
        provider_id: Some("input".to_string()),
        endpoint_id: Some("default".to_string()),
        provider_endpoint_key: Some("codex/input/default".to_string()),
        upstream_base_url: "https://input.example/v1",
        session_id: Some("sid-1".to_string()),
        cwd: Some("/workdir".to_string()),
        reasoning_effort: Some("medium".to_string()),
        service_tier: ServiceTierLog::default(),
        usage: None,
        http_debug: None,
        http_debug_ref: None,
        retry: None,
    })
    .expect("serialize request log");

    assert!(value["station_name"].is_null());
    assert_eq!(value["provider_id"].as_str(), Some("input"));
    assert_eq!(value["endpoint_id"].as_str(), Some("default"));
    assert_eq!(
        value["provider_endpoint_key"].as_str(),
        Some("codex/input/default")
    );
}

#[test]
fn route_attempts_are_derived_from_legacy_retry_chain() {
    let chain = vec![
        "right:https://api.right.example/v1 (idx=0) transport_error=operation timed out model=gpt-5".to_string(),
        "alpha:https://one.example/v1 (idx=1) skipped_unsupported_model=gpt-5".to_string(),
        "https://api.vibe.example/v1 (idx=2) status=200 class=- model=gpt-5-fast".to_string(),
        "all_upstreams_avoided total=3".to_string(),
    ];

    let attempts = parse_route_attempts_from_chain(&chain);

    assert_eq!(attempts.len(), 4);
    assert_eq!(attempts[0].attempt_index, 0);
    assert_eq!(attempts[0].station_name.as_deref(), Some("right"));
    assert_eq!(
        attempts[0].upstream_base_url.as_deref(),
        Some("https://api.right.example/v1")
    );
    assert_eq!(attempts[0].upstream_index, Some(0));
    assert_eq!(attempts[0].decision, "failed_transport");
    assert_eq!(
        attempts[0].error_class.as_deref(),
        Some("upstream_transport_error")
    );
    assert_eq!(attempts[0].model.as_deref(), Some("gpt-5"));
    assert_eq!(attempts[0].reason.as_deref(), Some("operation timed out"));

    assert_eq!(attempts[1].decision, "skipped_capability_mismatch");
    assert!(attempts[1].skipped);
    assert_eq!(attempts[1].station_name.as_deref(), Some("alpha"));
    assert_eq!(attempts[1].model.as_deref(), Some("gpt-5"));

    assert_eq!(attempts[2].decision, "completed");
    assert_eq!(attempts[2].status_code, Some(200));
    assert_eq!(attempts[2].station_name, None);
    assert_eq!(
        attempts[2].upstream_base_url.as_deref(),
        Some("https://api.vibe.example/v1")
    );

    assert_eq!(attempts[3].decision, "all_upstreams_avoided");
    assert_eq!(attempts[3].reason.as_deref(), Some("total=3"));
    assert!(attempts[3].skipped);
}

#[test]
fn route_attempts_are_derived_from_provider_endpoint_retry_chain() {
    let chain = vec![
        "endpoint=codex/input/default group=0 compat_station=routing upstream_index=0 url=https://input.example/v1 status=502 class=server_error model=gpt-5".to_string(),
        "endpoint=codex/right/default group=1 compat_station=routing upstream_index=2 url=https://right.example/v1 skipped_unsupported_model=gpt-5".to_string(),
    ];

    let attempts = parse_route_attempts_from_chain(&chain);

    assert_eq!(attempts.len(), 2);
    assert_eq!(
        attempts[0].provider_endpoint_key.as_deref(),
        Some("codex/input/default")
    );
    assert_eq!(attempts[0].provider_id.as_deref(), Some("input"));
    assert_eq!(attempts[0].endpoint_id.as_deref(), Some("default"));
    assert_eq!(attempts[0].preference_group, Some(0));
    assert_eq!(attempts[0].station_name.as_deref(), Some("routing"));
    assert_eq!(attempts[0].upstream_index, Some(0));
    assert_eq!(
        attempts[0].upstream_base_url.as_deref(),
        Some("https://input.example/v1")
    );
    assert_eq!(attempts[0].decision, "failed_status");

    assert_eq!(
        attempts[1].provider_endpoint_key.as_deref(),
        Some("codex/right/default")
    );
    assert_eq!(attempts[1].provider_id.as_deref(), Some("right"));
    assert_eq!(attempts[1].preference_group, Some(1));
    assert_eq!(attempts[1].decision, "skipped_capability_mismatch");
    assert!(attempts[1].skipped);
}

#[test]
fn retry_info_serializes_structured_route_attempts() {
    let upstream_chain = vec![
        "right:https://api.right.example/v1 (idx=0) transport_error=timeout model=gpt-5"
            .to_string(),
        "https://api.vibe.example/v1 (idx=1) status=200 class=- model=gpt-5".to_string(),
    ];
    let retry = RetryInfo {
        attempts: 2,
        route_attempts: parse_route_attempts_from_chain(&upstream_chain),
        upstream_chain,
    };

    let value = serde_json::to_value(&retry).expect("serialize retry info");

    assert_eq!(value["attempts"].as_u64(), Some(2));
    assert_eq!(
        value["route_attempts"][0]["decision"].as_str(),
        Some("failed_transport")
    );
    assert_eq!(
        value["route_attempts"][0]["station_name"].as_str(),
        Some("right")
    );
    assert_eq!(
        value["route_attempts"][1]["decision"].as_str(),
        Some("completed")
    );
}

#[test]
fn retry_info_station_helpers_use_derived_route_attempts() {
    let retry = RetryInfo {
        attempts: 2,
        upstream_chain: vec![
            "backup:https://api.backup.example/v1 (idx=0) transport_error=timeout model=gpt-5"
                .to_string(),
            "https://api.primary.example/v1 (idx=1) status=200 class=- model=gpt-5".to_string(),
        ],
        route_attempts: Vec::new(),
    };

    assert!(retry.touches_station("backup"));
    assert!(retry.touched_other_station(Some("primary")));
    assert!(!retry.touched_other_station(Some("backup")));
}