gproxy-transform 3.0.2

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

use crate::TransformError;

pub(crate) fn claude_to_openai(model: claude::ModelInfo) -> Result<openai::Model, TransformError> {
    let thinking_supported = model
        .capabilities
        .as_ref()
        .map(|capabilities| capabilities.thinking.supported);
    Ok(crate::wire!(openai::Model {
        id: wire_string(&model.id)?.into(),
        created: None,
        display_name: model.display_name,
        description: None,
        instructions: None,
        context_window: model.max_input_tokens,
        max_context_window: None,
        max_output_tokens: model.max_tokens,
        thinking_supported,
        input_modalities: model.capabilities.as_ref().map(|capabilities| {
            let mut values = vec!["text".into()];
            if capabilities.image_input.supported {
                values.push("image".into());
            }
            if capabilities.pdf_input.supported {
                values.push("pdf".into());
            }
            values
        }),
        output_modalities: None,
        supported_parameters: model.capabilities.as_ref().map(supported_parameters),
        supported_reasoning_levels: model.capabilities.as_ref().map(reasoning_levels),
        default_reasoning_level: None,
        service_tiers: None,
        default_service_tier: None,
        generation_methods: None,
        supported_actions: None,
        object: openai::ModelObjectType::Model,
        owned_by: Some("unknown".into()),
        rest: Default::default(),
    }))
}

pub(crate) fn openai_to_claude(model: openai::Model) -> Result<claude::ModelInfo, TransformError> {
    let id = wire_string(&model.id)?;
    let capabilities = capabilities(&model);
    Ok(crate::wire!(claude::ModelInfo {
        id: id.clone().into(),
        allowed_fallback_models: None,
        type_: claude::ModelObjectType::Known(claude::ModelObjectTypeKnown::Model),
        created_at: Some("1970-01-01T00:00:00Z".into()),
        display_name: model.display_name.or(Some(id)),
        max_input_tokens: model.context_window.or(model.max_context_window),
        max_tokens: model.max_output_tokens,
        capabilities,
        rest: Default::default(),
    }))
}

fn capabilities(model: &openai::Model) -> Option<claude::ModelCapabilities> {
    let parameters = model.supported_parameters.as_deref().unwrap_or_default();
    let modalities = model.input_modalities.as_deref().unwrap_or_default();
    if model.thinking_supported.is_none()
        && model.supported_parameters.is_none()
        && model.input_modalities.is_none()
        && model.supported_reasoning_levels.is_none()
    {
        return None;
    }
    let has = |name: &str| parameters.iter().any(|value| value == name);
    let support = |supported| {
        crate::wire!(claude::CapabilitySupport {
            supported,
            rest: Default::default(),
        })
    };
    let effort = |name: &str| {
        model
            .supported_reasoning_levels
            .as_ref()
            .is_some_and(|values| values.iter().any(|value| value.effort == name))
            .then(|| support(true))
    };
    Some(crate::wire!(claude::ModelCapabilities {
        batch: support(has("batch")),
        citations: support(has("citations")),
        code_execution: support(has("code_execution")),
        context_management: claude::ContextManagementCapability {
            supported: has("context_management"),
            clear_thinking_20251015: None,
            clear_tool_uses_20250919: None,
            compact_20260112: None,
            rest: Default::default(),
        },
        effort: claude::EffortCapability {
            supported: model
                .supported_reasoning_levels
                .as_ref()
                .is_some_and(|v| !v.is_empty()),
            low: effort("low"),
            medium: effort("medium"),
            high: effort("high"),
            xhigh: effort("xhigh"),
            max: effort("max"),
            rest: Default::default(),
        },
        image_input: support(modalities.iter().any(|value| value == "image")),
        pdf_input: support(
            modalities
                .iter()
                .any(|value| value == "pdf" || value == "file")
        ),
        structured_outputs: support(has("structured_outputs")),
        thinking: claude::ThinkingCapability {
            supported: model.thinking_supported.unwrap_or(false),
            types: claude::ThinkingTypes {
                adaptive: None,
                enabled: None,
                rest: Default::default(),
            },
            rest: Default::default(),
        },
        rest: Default::default(),
    }))
}

fn supported_parameters(capabilities: &claude::ModelCapabilities) -> Vec<String> {
    [
        (capabilities.batch.supported, "batch"),
        (capabilities.citations.supported, "citations"),
        (capabilities.code_execution.supported, "code_execution"),
        (
            capabilities.structured_outputs.supported,
            "structured_outputs",
        ),
        (capabilities.thinking.supported, "reasoning"),
    ]
    .into_iter()
    .filter(|(supported, _)| *supported)
    .map(|(_, name)| name.into())
    .collect()
}

fn reasoning_levels(capabilities: &claude::ModelCapabilities) -> Vec<openai::ModelReasoningLevel> {
    [
        (capabilities.effort.low.as_ref(), "low"),
        (capabilities.effort.medium.as_ref(), "medium"),
        (capabilities.effort.high.as_ref(), "high"),
        (capabilities.effort.xhigh.as_ref(), "xhigh"),
        (capabilities.effort.max.as_ref(), "max"),
    ]
    .into_iter()
    .filter(|(support, _)| support.is_some_and(|value| value.supported))
    .map(|(_, effort)| {
        crate::wire!(openai::ModelReasoningLevel {
            effort: effort.into(),
            description: String::new(),
        })
    })
    .collect()
}

pub(crate) fn wire_string<T: serde::Serialize>(value: &T) -> Result<String, TransformError> {
    serde_json::to_value(value)?
        .as_str()
        .map(str::to_owned)
        .ok_or_else(|| TransformError::shape("model id", "expected a string"))
}