gproxy-transform 3.0.0-alpha.0

GPROXY v3 pairwise protocol transforms
Documentation
use gproxy_protocol::{claude, openai};

pub(crate) fn claude_to_chat(usage: claude::Usage) -> Option<openai::CompletionUsage> {
    let cached = usage.cache_read_input_tokens;
    let cache_write = usage.cache_creation_total();
    let input = add_present(usage.input_tokens?, [cached, cache_write]);
    let output = usage.output_tokens?;
    Some(crate::wire!(openai::CompletionUsage {
        completion_tokens: clamp(output),
        prompt_tokens: clamp(input),
        total_tokens: clamp(input.saturating_add(output)),
        completion_tokens_details: usage.output_tokens_details.map(|details| {
            openai::CompletionTokensDetails {
                accepted_prediction_tokens: None,
                audio_tokens: None,
                reasoning_tokens: details.thinking_tokens.map(clamp),
                rejected_prediction_tokens: None,
                rest: Default::default(),
            }
        }),
        prompt_tokens_details: cache_details_to_chat(cached, cache_write),
        rest: Default::default(),
    }))
}

pub(crate) fn chat_to_claude(usage: Option<openai::CompletionUsage>) -> Option<claude::Usage> {
    let usage = usage?;
    let cached = usage
        .prompt_tokens_details
        .as_ref()
        .and_then(|details| details.cached_tokens);
    let cache_write = usage
        .prompt_tokens_details
        .as_ref()
        .and_then(|details| details.cache_write_tokens);
    Some(crate::wire!(claude::Usage {
        input_tokens: Some(subtract_present(
            u64::from(usage.prompt_tokens),
            [cached, cache_write],
        )),
        output_tokens: Some(u64::from(usage.completion_tokens)),
        cache_creation_input_tokens: cache_write.map(u64::from),
        cache_read_input_tokens: cached.map(u64::from),
        cache_creation: None,
        output_tokens_details: usage.completion_tokens_details.map(|details| {
            claude::OutputTokensDetails {
                thinking_tokens: details.reasoning_tokens.map(u64::from),
                rest: Default::default(),
            }
        }),
        server_tool_use: None,
        iterations: None,
        inference_geo: None,
        service_tier: None,
        speed: None,
        rest: Default::default(),
    }))
}

pub(crate) fn claude_to_responses(usage: claude::Usage) -> Option<openai::ResponseUsage> {
    let cached = usage.cache_read_input_tokens;
    let cache_write = usage.cache_creation_total();
    let input = add_present(usage.input_tokens?, [cached, cache_write]);
    let output = usage.output_tokens?;
    Some(crate::wire!(openai::ResponseUsage {
        input_tokens: clamp(input),
        output_tokens: clamp(output),
        total_tokens: clamp(input.saturating_add(output)),
        input_tokens_details: cache_details_to_responses(cached, cache_write),
        output_tokens_details: usage.output_tokens_details.map(|details| {
            openai::ResponseOutputTokensDetails {
                reasoning_tokens: details.thinking_tokens.map(clamp),
                rest: Default::default(),
            }
        }),
        rest: Default::default(),
    }))
}

pub(crate) fn responses_to_claude(usage: Option<openai::ResponseUsage>) -> Option<claude::Usage> {
    let usage = usage?;
    let cached = usage
        .input_tokens_details
        .as_ref()
        .and_then(|details| details.cached_tokens);
    let cache_write = usage
        .input_tokens_details
        .as_ref()
        .and_then(|details| details.cache_write_tokens);
    Some(crate::wire!(claude::Usage {
        input_tokens: Some(subtract_present(
            u64::from(usage.input_tokens),
            [cached, cache_write],
        )),
        output_tokens: Some(u64::from(usage.output_tokens)),
        cache_creation_input_tokens: cache_write.map(u64::from),
        cache_read_input_tokens: cached.map(u64::from),
        cache_creation: None,
        output_tokens_details: usage.output_tokens_details.map(|details| {
            claude::OutputTokensDetails {
                thinking_tokens: details.reasoning_tokens.map(u64::from),
                rest: Default::default(),
            }
        }),
        server_tool_use: None,
        iterations: None,
        inference_geo: None,
        service_tier: None,
        speed: None,
        rest: Default::default(),
    }))
}

pub(crate) fn responses_to_chat(usage: openai::ResponseUsage) -> openai::CompletionUsage {
    crate::wire!(openai::CompletionUsage {
        completion_tokens: usage.output_tokens,
        prompt_tokens: usage.input_tokens,
        total_tokens: usage.total_tokens,
        completion_tokens_details: usage.output_tokens_details.map(|details| {
            openai::CompletionTokensDetails {
                accepted_prediction_tokens: None,
                audio_tokens: None,
                reasoning_tokens: details.reasoning_tokens,
                rejected_prediction_tokens: None,
                rest: Default::default(),
            }
        }),
        prompt_tokens_details: usage.input_tokens_details.map(|details| {
            openai::PromptTokensDetails {
                audio_tokens: None,
                cache_write_tokens: details.cache_write_tokens,
                cached_tokens: details.cached_tokens,
                rest: Default::default(),
            }
        }),
        rest: Default::default(),
    })
}

pub(crate) fn chat_to_responses(usage: openai::CompletionUsage) -> openai::ResponseUsage {
    crate::wire!(openai::ResponseUsage {
        input_tokens: usage.prompt_tokens,
        output_tokens: usage.completion_tokens,
        total_tokens: usage.total_tokens,
        input_tokens_details: usage.prompt_tokens_details.map(|details| {
            openai::ResponseInputTokensDetails {
                cache_write_tokens: details.cache_write_tokens,
                cached_tokens: details.cached_tokens,
                rest: Default::default(),
            }
        }),
        output_tokens_details: usage.completion_tokens_details.map(|details| {
            openai::ResponseOutputTokensDetails {
                reasoning_tokens: details.reasoning_tokens,
                rest: Default::default(),
            }
        }),
        rest: Default::default(),
    })
}

fn cache_details_to_chat(
    cached: Option<u64>,
    cache_write: Option<u64>,
) -> Option<openai::PromptTokensDetails> {
    (cached.is_some() || cache_write.is_some()).then(|| {
        crate::wire!(openai::PromptTokensDetails {
            audio_tokens: None,
            cache_write_tokens: cache_write.map(clamp),
            cached_tokens: cached.map(clamp),
            rest: Default::default(),
        })
    })
}

fn cache_details_to_responses(
    cached: Option<u64>,
    cache_write: Option<u64>,
) -> Option<openai::ResponseInputTokensDetails> {
    (cached.is_some() || cache_write.is_some()).then(|| {
        crate::wire!(openai::ResponseInputTokensDetails {
            cache_write_tokens: cache_write.map(clamp),
            cached_tokens: cached.map(clamp),
            rest: Default::default(),
        })
    })
}

fn add_present<const N: usize>(mut value: u64, details: [Option<u64>; N]) -> u64 {
    for detail in details.into_iter().flatten() {
        value = value.saturating_add(detail);
    }
    value
}

fn subtract_present<const N: usize>(mut value: u64, details: [Option<u32>; N]) -> u64 {
    for detail in details.into_iter().flatten() {
        value = value.saturating_sub(u64::from(detail));
    }
    value
}

fn clamp(value: u64) -> u32 {
    value.min(u64::from(u32::MAX)) as u32
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_usage_and_optional_details_stay_absent() {
        let usage = crate::wire!(claude::Usage {
            input_tokens: Some(10),
            output_tokens: Some(3),
            cache_creation_input_tokens: None,
            cache_read_input_tokens: Some(2),
            cache_creation: None,
            output_tokens_details: None,
            server_tool_use: None,
            iterations: None,
            inference_geo: None,
            service_tier: None,
            speed: None,
            rest: Default::default(),
        });
        let response = claude_to_responses(usage.clone()).unwrap();
        let details = response.input_tokens_details.as_ref().unwrap();
        assert_eq!(details.cached_tokens, Some(2));
        assert_eq!(details.cache_write_tokens, None);
        assert_eq!(response.output_tokens_details, None);

        let chat = claude_to_chat(usage).unwrap();
        let details = chat.prompt_tokens_details.as_ref().unwrap();
        assert_eq!(details.cached_tokens, Some(2));
        assert_eq!(details.cache_write_tokens, None);
        assert_eq!(chat.completion_tokens_details, None);

        assert_eq!(chat_to_claude(None), None);
        assert_eq!(responses_to_claude(None), None);

        let incomplete = crate::wire!(claude::Usage {
            input_tokens: Some(10),
            output_tokens: None,
            cache_creation_input_tokens: None,
            cache_read_input_tokens: None,
            cache_creation: None,
            output_tokens_details: None,
            server_tool_use: None,
            iterations: None,
            inference_geo: None,
            service_tier: None,
            speed: None,
            rest: Default::default(),
        });
        assert_eq!(claude_to_chat(incomplete.clone()), None);
        assert_eq!(claude_to_responses(incomplete), None);
    }
}